diff --git a/.gitignore b/.gitignore index e721375..a2fb2af 100644 --- a/.gitignore +++ b/.gitignore @@ -19,11 +19,23 @@ # production /build +apps/boss-admin-web/dist/ +apps/boss-admin-web/node_modules/ # misc .DS_Store *.pem .playwright-cli/ +.playwright-mcp/ +.superpowers/ +output/ +admin-redesign*.png +main-*.js +android/.project +android/.settings/ +android/app/.classpath +android/app/.project +android/app/.settings/ data/*.json data/*.json.bak android/.gradle/ diff --git a/README.md b/README.md index 66ca8e0..2364bc3 100644 --- a/README.md +++ b/README.md @@ -10,8 +10,9 @@ 2. `docs/architecture/repo_map_cn.md` 3. `docs/architecture/current_runtime_and_deploy_status_cn.md` 4. `docs/architecture/api_and_service_inventory_cn.md` -5. `docs/architecture/boss_server_connection_and_deploy_cn.md` -6. `prompts/codex_fullstack_build_and_deploy_prompt_cn.md` +5. `docs/architecture/rbac_skill_regression_matrix_cn.md` +6. `docs/architecture/boss_server_connection_and_deploy_cn.md` +7. `prompts/codex_fullstack_build_and_deploy_prompt_cn.md` ## 当前有效目录 @@ -53,17 +54,33 @@ - `POST http://127.0.0.1:3000/api/auth/restore` 正常,已验证可用原生 restore token 恢复登录态 - `POST http://127.0.0.1:3000/api/v1/projects/master-agent/messages` 正常,已验证可通过 `Mac Studio local-agent -> 本机 Master Codex Node -> 回写项目账本` 返回真实主 Agent 回复 - `POST http://127.0.0.1:3000/api/v1/projects/[projectId]/messages` 正常,普通单线程会话当前会返回 `conversation_reply` 任务,并等待绑定设备上的真实 Codex 线程回写 +- `POST http://127.0.0.1:3000/api/v1/integrations/telegram/webhook` 正常,已支持 Telegram Bot 私聊消息直连 Boss 主 Agent;快速回复会立即回 Telegram,异步任务完成后也会自动回推 +- `GET/POST http://127.0.0.1:3000/api/v1/integrations/telegram` 正常,已支持最高管理员读取和保存 Telegram 接入配置,返回默认脱敏视图;保存 webhook 模式时会自动调用 Telegram `setWebhook`,切回 polling/关闭时会自动调用 `deleteWebhook`;Web `/me/telegram` 与原生 Android `我的 > Telegram 接入` 都已接入这条配置链路,并支持把群 / Topic 路由到指定 Boss 项目 - `POST http://127.0.0.1:3000/api/auth/logout` 正常,退出后访问受保护 `/api/v1/*` 会返回 `401` +- `GET/POST http://127.0.0.1:3000/api/v1/auth/sessions` 正常,已支持查看当前账号登录会话、最高管理员查看全部活跃会话,以及撤销单个登录端;返回内容不会暴露 `sessionToken / restoreToken` +- 当前多用户 / RBAC 第一阶段已经落地:`BossState` 新增 `accountDeviceGrants / accountProjectGrants / accountSkillGrants / skillCatalog / permissionAuditLogs`,所有会话、设备、项目详情、消息读写、设备 Skill 和 `/api/state` 都会按当前登录账号过滤;最高管理员仍保持全局可见 +- `GET/POST http://127.0.0.1:3000/api/v1/admin/access` 正常,仅最高管理员可用;当前支持创建/更新子账号、公司启用/停用、账号/设备归属、批量导入预览、批量导入、重置子账号密码、离职回收、授予设备/项目/Skill 权限、套用权限模板和撤销授权,返回账号时不会暴露 `passwordHash` +- `GET http://127.0.0.1:3000/api/v1/admin/overview`、`POST http://127.0.0.1:3000/api/v1/admin/risks/scan` 和 `POST http://127.0.0.1:3000/api/v1/admin/notifications/dispatch` 正常,仅最高管理员可用;风险扫描会把超时 SLA 幂等写入 `adminNotifications`,派发结果和处置动作写入 `adminRiskTimeline` +- `GET/POST http://127.0.0.1:3000/api/v1/admin/skills/requests` 正常,仅最高管理员可用;当前支持对指定设备创建 `install / update / uninstall / rollback / version_lock` 请求,local-agent 会通过设备 token 认领、执行本机 Skill 文件操作或 Git 操作,并把完成状态和最新 Skill 清单回写 +- 当前 Web `/me/access` 和原生 Android `我的 > 用户与权限` 已接入授权管理:最高管理员可在前台创建子账号、授予设备/项目/Skill 权限、套用 `只读观察员 / 项目开发者 / 设备操作者` 模板、查看同名 Skill 跨设备聚合和撤销授权;`admin/member` 不显示该入口 +- 当前主 Agent 执行上下文已接入授权快照:主 Agent 生成提示词和任务时只带当前账号可见的设备、项目、线程状态文档、进展事件和 Skill,并在 `MasterAgentTask` 上记录 `authorizedDeviceIds / authorizedProjectIds / authorizedSkillIds / requiredPermissions` +- 多用户 / RBAC / Skill / 主 Agent 权限和多设备控制的集中状态、回归矩阵与缺口清单见 `docs/architecture/rbac_skill_regression_matrix_cn.md` - `GET http://127.0.0.1:3000/api/v1/user/ota/package` 正常,当前会返回最新 APK 包 - 当前这台开发机的 `launchd` 常驻 `local-agent` 已恢复:`GET http://127.0.0.1:4317/health` 现在可在数十毫秒内返回,且在手动 heartbeat 执行期间仍能正常回包 - 当前 Boss 已新增 `src/lib/execution/` 执行底座抽象层;当前生产主链仍然沿用 `local-agent -> codex exec resume`,只是执行责任已开始通过 `ExecutionBackend / PromptAssembler / PermissionPolicy / RemoteRuntimeAdapter / OrchestrationBackend` 默认实现收束 - 当前 `claw-code` 已以最小 `ClawBackendAdapter` 形式接入执行底座,但默认关闭;只有在显式配置 `BOSS_CLAW_*` 且可用性探测通过时,`master-agent` 当前对话里才会出现并允许选择 `claw-runtime` +- 当前已新增最小 `Telegram Gateway`:Boss 服务器可直接作为 Telegram Bot webhook 入口,把 Telegram 私聊或受控群聊文本桥接进 `master-agent` 或指定 Boss 项目,并在 `master-agent task complete` 后自动把结果回推给 Telegram 用户;Android 原生端已提供 `TelegramIntegrationActivity`,可查看 Bot 状态、配置 webhook、白名单、群聊触发策略和群 / Topic 路由 - 如果历史上已经保存过 `backendOverride=claw-runtime`,但当前 `Claw Runtime` 不可用,运行时会自动回退到默认后端,并在前台显示明确原因 - 当前 `oh-my-codex` 已以最小 `OmxTeamBackendAdapter` 形式接入执行底座,但默认关闭;当前已经接到 Web 群聊详情页 / 原生群资料页的编排后端选择卡,可在 `Boss Native` 与 `OMX Team` 间切换,OMX 不可用时会自动回退到默认后端并明确提示原因 - 当前仓库已自带一个本地 OMX smoke runtime:`scripts/omx-team-smoke.mjs`。在还没有真实 `oh-my-codex` 可执行文件时,可以先用它验证 `OmxTeamBackendAdapter -> selector -> dispatch_execution -> 回写群聊账本` 这条链 - 当前仓库已自带一个本地 smoke runtime:`scripts/claw-runtime-smoke.mjs`。在还没有真实 `claw-code` 可执行文件时,可以先用它验证 `ClawBackendAdapter -> backendOverride -> 异步回流` 整条链 +- 当前已新增“Boss 统一电脑控制中枢”第一批能力:主 Agent 已能把聊天输入区分为 `discussion / development / browser / desktop` 四类意图,并能把 `browser_control / desktop_control` 作为正式任务排入 `MasterAgentTask` 队列;本机 `local-agent` 已补上 `browser-control-task-runner / computer-use-task-runner` 外部 runtime 桥,可通过 `browserControl* / computerUse*` 配置接入真实 Browser Automation 与 Computer Use 执行器,未启用时会 fail closed,不再假装执行成功 +- 当前 browser/desktop 控制结果已经会作为 `control_summary` 正式写回会话账本,并保留目标 URL / 应用名;Android 原生端会以单独控制结果卡片展示,便于把“执行什么”和“执行结果”与普通聊天正文区分开 +- 当前 `scripts/browser-control-smoke.mjs` 已经能对目标 URL 做一次真实最小探测:抓取页面标题并写回聊天结果;`scripts/computer-use-smoke.mjs` 已支持 macOS `osascript` 应用激活、引号文本输入、可选回车发送、`open -a` 兜底打开和执行 artifact 落盘,作为后续接完整 Computer Use 前的稳定过渡层 +- 当前默认本机配置已把 `browserAutomation / computerUse` 两项能力直接上报为在线起步态,所以 Boss App 里这台 Mac 会显示“可做浏览器控制 / 桌面控制”;如果某条链路要临时收起,只需要改 `local-agent/config.cloud.json` - `GET http://127.0.0.1:4317/api/v1/skills` 正常,已返回本机扫描到的 Codex Skill - `POST http://127.0.0.1:4317/api/v1/heartbeat` 正常,且会顺带触发 `thread-context` 上报 +- `local-agent` 当前每 5 秒轮询一次本机 Skill lifecycle 请求;默认打开 `skillLifecycleEnabled=true`。远程 `install` 或带 `sourceUrl` 的更新必须命中 `skillLifecycleAllowedSources` 或 `skillLifecycleTrustedSources`,为空时只允许既有本地 Skill 的 `update / rollback / uninstall / version_lock`;请求携带 `checksum / expectedChecksum` 时会校验 `manifest.json` 或 `SKILL.md` 的 sha256,失败会清理半安装目录或尽量恢复备份。卸载 / 更新 / 回滚前会在 `skillsDir/.boss-skill-backups` 保留备份,卸载仍限制在 `skillsDir` 目录内,版本锁写入 `.boss-skill-locks.json` - `launchd` 已加载:`~/Library/LaunchAgents/com.hyzq.boss.local-agent.plist` 服务器: @@ -103,13 +120,14 @@ Android APK: - `npm run apk:release` 还会额外产出带版本号的文件:`android/app/build/outputs/apk/release/boss-android-v{versionName}-release.apk` - 当前最新 release 构建版本:`2.5.11`(`versionCode=24`) - 当前 APK 已切到原生 Android 客户端:`MainActivity + BossApiClient + 原生 XML 布局` -- 真机开发约束:除非用户明确要求切换设备,后续 Android 开发、ADB 安装、交互回归与问题复现统一只使用 `PLB110`;如果 `PLB110` 当前不在线,应先恢复这台设备连接,不自动切到其他手机 +- 真机开发约束:用户已明确切换到当前连接的 OPPO `PHZ110`(ADB serial `U84XJRIB7D65ZH45`);除非用户再次要求切换设备,后续 Android 开发、ADB 安装、交互回归与问题复现统一使用这台 OPPO,不再回退到原 `PLB110` +- OPPO 权限回归建议命令:`adb -s U84XJRIB7D65ZH45 devices -l`、`./gradlew :app:assembleDebug`、`adb -s U84XJRIB7D65ZH45 install -r android/app/build/outputs/apk/debug/app-debug.apk`、`adb -s U84XJRIB7D65ZH45 shell am start -W -n com.hyzq.boss/.MainActivity -e initial_tab me`,再从 `我的 > 用户与权限` 确认最高管理员可进入权限页。 - Android 真机无线调试如果要尽量稳定,优先使用“同一局域网 + 初次 USB 启用后执行 `adb tcpip 5555` + `adb connect :5555`”这条链路;它通常比只依赖系统“无线调试配对码”更稳 - Android 系统层面对“无线调试”没有真正的永久不掉线开关;重启手机、切 Wi‑Fi、切热点、ADB server 重启、USB 调试被重新切换后,都可能导致无线调试自动失效 - 真机调试时建议固定同一 SSID、避免代理/VPN 改路、开发者选项里开启“保持唤醒”,并在需要长时间稳定调试时优先保留 USB 兜底;如果必须完全避免自动断开,不要只依赖无线调试 - 当前原生活动页已经覆盖:会话首页、项目详情、项目目标、版本记录、会话信息、群资料、发起群聊、消息转发、线程详情、设备详情、添加设备、账号与安全、设置、AI 账号、主 Agent 提示词 / 记忆、技能、运维中心、关于 - 当前原生一级体验已回退到微信式交互:`会话 / 设备 / 我的` 固定底部 tab,会话首页是简单聊天列表,`主 Agent / 审计对话` 以普通置顶会话样式排在最前;项目详情页是聊天优先,只保留 `项目目标 / 版本记录` 两个轻入口 -- 当前会话首页右上角已切回 `+` 入口:直接从首页发起独立群聊;设备页右上角仍是 `+添加` +- 当前会话首页右上角已切回 `+` 入口:直接从首页发起独立群聊;设备页右上角 `+添加` 仅最高管理员可见,子账号只保留刷新。 - 当前会话首页已升级成“项目聚合 + 线程下钻”的结构:如果某个 Codex 文件夹只导入了 1 个线程,会话列表直接显示这个线程;如果同一文件夹导入了多个线程,会话首页只显示该文件夹归档项,点进去再看这个项目下的全部线程 - 当前会话首页的数据源已分成两层:`/api/v1/conversations` 继续保留平铺线程视图给群聊创建、转发等内部能力使用;首页和原生根页改走 `/api/v1/conversations/home`,文件夹归档详情走 `/api/v1/conversation-folders/[folderKey]` - 当前会话搜索仍然保留线程可达性:如果命中单线程项目,会直接进入该线程;如果命中多线程项目里的某条线程,结果会显示 `项目 / 线程`,点击后先进入项目文件夹页并定位到对应线程,不会把首页重新打平成线程列表 @@ -148,6 +166,7 @@ Android APK: - 当前设备导入 `review` 已补 owner/admin 鉴权,并改成真正的异步审核链:`review` 只负责排队 `device_import_resolution` 任务并返回 queued 状态,等 local-agent 完成回写后才把决议写回草稿和会话账本 - 当前原生 APP 会话页的“刷新失败”已按当前 tab 的主数据源独立判错:`会话` 只看会话请求本身,`设备` 只看设备请求,`我的` 只在 `settings + ota` 同时失败时才提示刷新失败 - 当前 `设备` 和 `我的` 根页已收口为简单列表;`运维与修复 / AI 账号 / 技能` 保留在一级 `我的`,`审计对话` 作为置顶会话保留在会话首页 +- 当前原生 `我的` 根页已开始按登录角色过滤入口:`member` 只显示个人安全、设置、已授权 Skill 和关于;`admin / highest_admin` 才显示运维、AI 账号、附件存储和 Telegram 管理入口;`用户与权限` 仅 `highest_admin` 可见 - 原生客户端当前直接调用 `https://boss.hyzq.net` 的 Boss API,不再打开 WebView - `2.0.1` 已修复华为真机上因 `Theme.SplashScreen` 与 `AppCompatActivity` 不兼容导致的启动闪退 - `2.1.0` 已在本机连接的华为真机上完成签名包覆盖安装与启动复核,原生三栏入口和子活动页声明已全部接通 @@ -160,6 +179,7 @@ Android APK: - `2.5.1` 继续收口微信式原生 UI:聊天页普通态顶部已隐藏刷新按钮,只保留右上角“信息”;发起群聊页顶部说明和选择区已压成更轻的会话式密度,候选线程继续复用微信式会话卡片 - `2.5.2` 继续补齐深层原生页:`项目目标 / 版本迭代记录 / 会话信息 / 群资料` 已进一步向设计图收口;附件消息卡片的分析状态和动作文案也压成了更轻的微信式层级 - `2.5.4` 已把 `我的` 根页收口成微信式资料区 + 白底菜单列表,并同步把 `设置 / 账号与安全 / AI 账号 / 技能 / 运维与修复` 的顶部说明从重 `soft panel` 降成轻量列表说明 +- `2.5.11` 已补齐第一批遗漏功能:聊天长按“删除”接通服务端账本删除与实时刷新;原生 `我的 > 附件与存储` 可直接切换服务器文件存储 / 阿里 OSS;后台通知覆盖所有会话里的主 Agent 回复;browser/desktop runtime 未配置时改为明确失败而不是占位成功 - `2.5.5` 已补上群资料页的“修复群成员”主链:历史脏群会明确提示失效成员,并允许重新选择真实线程成员写回群资料;`approval_required` 群聊也已补齐“确认 / 拒绝”两条审批动作 ## 本地启动 @@ -189,6 +209,7 @@ npm start - 登录页:[http://127.0.0.1:3000/auth/login](http://127.0.0.1:3000/auth/login) - 会话页:[http://127.0.0.1:3000/conversations](http://127.0.0.1:3000/conversations) - 设备页:[http://127.0.0.1:3000/devices](http://127.0.0.1:3000/devices) +- 平台总后台:[http://127.0.0.1:3000/admin](http://127.0.0.1:3000/admin),生产计划独立入口为 `https://admin.boss.hyzq.net` ## 设备端本地服务 @@ -222,6 +243,8 @@ device-agent 当前职责: - 轮询云端 `/api/v1/master-agent/tasks/claim`,并用当前电脑已登录的 `codex` 账号执行主 Agent 任务 - 将主 Agent 执行结果回写到云端 `/api/v1/master-agent/tasks/[taskId]/complete` - 对普通单线程会话,认领到的 `conversation_reply` 任务会直接恢复到目标 Codex 线程,并把线程原始回复回写到对应聊天窗口 +- 对已绑定 `codexThreadRef` 的普通单线程会话,`local-agent` 会在执行 `codex exec resume` 前先把 Boss App 里的用户消息镜像进目标 Codex Desktop 线程 rollout,避免 APP 和桌面版同线程历史割裂;定位 rollout 时优先用 `state_5.sqlite`,不可用时回退扫描 `~/.codex/sessions`,并尽量刷新线程活跃时间。镜像成功后会优先调用本机常驻 `Codex Desktop Bridge` endpoint,再打开 `codex://threads/{threadId}` 并发送一次安全刷新提示,让桌面版切到目标线程后重新读取记录;endpoint 不可用时回退原命令式刷新。刷新桥默认对短暂失败重试 2 次、间隔 120ms,并保留 deep link 与尝试次数,便于追踪桌面同步是否真正触发。bridge 同时提供 `GET /api/v1/codex-desktop/events` SSE 和 recent 缓冲,后续 Codex Desktop 插件可直接订阅安全元数据事件;`scripts/codex-desktop-event-consumer.mjs` 可作为本机订阅 smoke +- `scripts/codex-desktop-integration-probe.mjs` 可探测本机 Codex Desktop 能力,bridge 也提供 `GET /api/v1/codex-desktop/capabilities`;探测只读 `Info.plist` 和 app 资源,明确不修改 Codex.app 签名包体 - 对群聊线程分发任务,认领到的 `dispatch_execution` 任务会把原始线程结果和主 Agent 汇总一起回写到群聊消息账本 - `local-agent` 对 `conversation_reply` 当前会优先使用 `codex exec resume `,只有缺失真实线程引用时才退回 `--ephemeral` - `local-agent` 对 `dispatch_execution` 当前会按 `orchestrationBackendId` 分流:默认继续走 `codex exec resume`;当任务显式选择 `omx-team` 且本机 `omxEnabled + omxCommand/omxArgs` 可用时,会改走 `OMX Team Runtime` JSON 协议执行 @@ -250,6 +273,8 @@ device-agent 当前职责: - APK 发布脚本:`scripts/publish-apk-to-public.sh` - `systemd` 配置:`deployment/systemd/boss-web.service` - `Caddy` 配置:`deployment/Caddyfile` +- 平台总后台域名解析:`admin.boss.hyzq.net` 需要在 DNSPod 添加 `A` 记录到 `106.53.170.158`,Caddy 已预留独立站点并把根路径跳到 `/admin` +- 服务器 Caddy 还有 `gptpluscontrol-boss-caddy-reconcile.timer` 周期性重写:如果改域名入口,必须同步更新 `/home/ubuntu/build/gptpluscontrol/deploy/server/caddy.boss_hyzq_net.gptpluscontrol.conf`,否则会再次生成重复站点块 - 邮件配置:`deployment/mail/` - Android 原生入口:`android/app/src/main/java/com/hyzq/boss/MainActivity.java` - Android API 客户端:`android/app/src/main/java/com/hyzq/boss/BossApiClient.java` @@ -344,21 +369,21 @@ npm run aab:release - 对已经绑定的生产设备,服务端现在会在 heartbeat 时自动选中建议导入项、生成导入决议并直接应用;因此会话页会自动出现这台设备当前真实运行的 Codex 线程窗口 - 对已经绑定的生产设备,服务端现在会在 heartbeat 时自动选中建议导入项、生成导入决议并直接应用;如果某个项目下存在多个线程,会话首页会先显示项目归档项,而不是把所有线程平铺在首页 - 对已经绑定的生产设备,如果某些自动导入线程已经不再出现在最新 `projectCandidates[]` 中,服务端会在下一次 heartbeat 自动清理这些过时会话,避免旧线程长期滞留首页 -- 登录页当前已临时切到免验证模式,点击“登录”会直接进入会话首页 -- 认证现在已经有最小会话链路:登录后会写入 `boss_session` Cookie,默认保持 30 天,`会话 / 设备 / 我的 / 线程` 页面以及主要 `/api/v1/*` 接口都要求有效会话 +- 认证现在已经有最小会话链路:登录后会写入 `boss_session` Cookie,默认保持 30 天,`会话 / 设备 / 我的 / 线程` 页面以及主要 `/api/v1/*` 接口都要求有效会话;临时免验证登录默认关闭,仅在显式设置 `BOSS_AUTH_AUTO_LOGIN=1/true/yes` 时启用 - 新增 `GET /api/auth/session`、`POST /api/auth/logout` 与 `POST /api/auth/restore` +- 当前同一账号已经支持多个登录端并存;Web 与原生 Android 的 `我的 > 账号与安全` 可查看和撤销登录会话,最高管理员可以管理所有活跃会话 - 原生 Android 客户端当前会把 `boss_session / restore token / account` 存到 `SharedPreferences`,用于重启后恢复会话 - 验证码新增防刷与防重放:60 秒冷却、15 分钟窗口限流,登录连续失败 5 次后会锁定 10 分钟 - `POST /api/auth/send-code` 现在会先按用途校验账号状态:登录 / 忘记密码要求账号已存在,注册要求账号尚未注册 -- 当前登录页已临时放开成“一键进入”,账号密码和验证码输入暂时不作为拦截条件 -- `POST /api/auth/send-code` 与固定验证码 `000000` 仍保留给注册 / 重置密码和后续认证收口,不作为当前登录页前置条件 +- 当前登录页默认走账号密码或验证码校验,不再把开发兜底作为生产默认能力 +- `POST /api/auth/send-code` 当前仍支持 fixed 模式,但验证码登录也必须先申请验证码并消费账本里的有效记录;不能只靠固定码直接登录 - 新注册和重置密码现在使用 `scrypt` 哈希;历史 `sha256` 密码会在下一次密码登录时自动迁移 - 原生 Android 当前把 `ProjectForwardActivity / ThreadDetailActivity / OpsCenterActivity` 等复杂能力下沉到二级或更深层入口,不再把线程预算 / 转发 / 运维说明堆在主聊天页和一级我的页 - 原生 OTA 当前除了整包下载和系统安装器拉起,还会在关于页保留本地下载状态;离开关于页再回来时,仍能看到进行中 / 失败 / 待授权 / 可安装状态 - Android 本地 Gradle 验证当前必须串行执行,避免并发 `testDebugUnitTest / compileDebugJavaWithJavac / assembleDebug` 相互踩坏中间产物 -- 当前默认最高管理员账号:`17600003315` -- 当前默认测试密码:`boss123456` -- 当前本机 Codex 节点 `mac-studio` 已绑定到 `17600003315` +- 当前默认最高管理员账号:`krisolo` +- 当前默认测试密码由线上初始化配置管理,文档不再明文记录 +- 当前本机 Codex 节点 `mac-studio` 已绑定到 `krisolo` - 主 Agent 对话当前真实执行链路是:`Boss Web -> 写入用户消息 -> 返回 queued/running -> master-agent task queue -> local-agent / OpenAI API -> complete task -> project ledger` - `master-agent` 单聊当前已改成“快速入队 + 异步回流”:发送后会立即返回任务包和 `masterReplyState`,前台先显示“主 Agent 思考中”,真实回复稍后自动回写到账本 - 原生 Android 当前会把 `master-agent` 的等待态保留在消息流里:发送后常驻显示“主 Agent 思考中”,超时后改成“主 Agent 回复超时 + 重试等待”,收到新回复后会自动清掉,不再只靠 toast 提示 @@ -369,7 +394,8 @@ npm run aab:release - 应用内 `GET /api/v1/user/ota` / `POST /api/v1/user/ota` / `GET /api/v1/user/ota/package` 现在已经支持 OTA 状态、检查更新、执行升级和 APK 包下载 - `GET /api/v1/app-logs` 现在已支持登录态下按 `deviceId / projectId / level / category / source / cursor` 查询日志分页 - 设备写接口 `POST /api/v1/app-logs`、`POST /api/v1/devices/[deviceId]/skills`、`POST /api/v1/workers/[workerId]/thread-context` 现在都要求有效设备 token 或匹配登录会话 -- 当前认证仍是 MVP:已有最小会话 Cookie,但还没有刷新令牌、跨端会话治理、吊销审计和 CSRF 防护 +- 当前认证已具备最小会话 Cookie、restore token 轮换、浏览器 CSRF 基础防护、子账号 MFA 开关、基础跨端会话治理和后台高危操作审计;后续仍可继续补企业 SSO / IdP +- 当前状态存储默认继续使用 `data/boss-state.json`;已新增 `BOSS_STATE_STORE=postgres` 适配层,生产切换 PostgreSQL 时必须配置 `BOSS_DATABASE_URL`,并先使用 `scripts/boss-state-store-maintenance.mjs` 做备份、dry-run 迁移和回滚演练 - 聊天附件当前已支持真实上传、消息落账本、受保护下载和原生打开;默认存储后端为服务器文件存储 - 当前用户已可在 `我的 > 附件与存储` 切到阿里 OSS 私有桶,下载链会按附件快照生成签名地址,避免用户后续修改配置后旧附件失效 - 图片 / PDF / 文本默认自动进入主 Agent 附件分析;视频 / Office / 大文件默认手动触发 diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 9ad5b9f..4d3b761 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -2,6 +2,9 @@ + + + + + + + + diff --git a/android/app/src/main/java/com/hyzq/boss/AboutActivity.java b/android/app/src/main/java/com/hyzq/boss/AboutActivity.java index 40dbeb9..485cb7d 100644 --- a/android/app/src/main/java/com/hyzq/boss/AboutActivity.java +++ b/android/app/src/main/java/com/hyzq/boss/AboutActivity.java @@ -15,6 +15,7 @@ import android.provider.Settings; import android.widget.LinearLayout; import androidx.annotation.Nullable; +import androidx.core.content.ContextCompat; import org.json.JSONArray; import org.json.JSONObject; @@ -76,11 +77,7 @@ public class AboutActivity extends BossScreenActivity { restoreDownloadUiState(); realtimeClient = new BossRealtimeClient(apiClient, this::handleRealtimeEvent); IntentFilter filter = new IntentFilter(DownloadManager.ACTION_DOWNLOAD_COMPLETE); - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - registerReceiver(otaDownloadReceiver, filter, Context.RECEIVER_NOT_EXPORTED); - } else { - registerReceiver(otaDownloadReceiver, filter); - } + ContextCompat.registerReceiver(this, otaDownloadReceiver, filter, ContextCompat.RECEIVER_NOT_EXPORTED); reload(); } @@ -491,11 +488,9 @@ public class AboutActivity extends BossScreenActivity { persistDownloadUiState(); refreshDownloadStateSection(); - if (!getPackageManager().canRequestPackageInstalls()) { + if (!canInstallDownloadedPackages()) { showMessage("请先允许 Boss 安装未知来源应用,然后重新打开安装包。"); - Intent intent = new Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES, Uri.parse("package:" + getPackageName())); - intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); - startActivity(intent); + openUnknownAppSourcesSettings(); return; } @@ -566,7 +561,7 @@ public class AboutActivity extends BossScreenActivity { return OtaDownloadStateMapper.failed(fileName); } if (downloadedApkUri != null) { - if (!getPackageManager().canRequestPackageInstalls()) { + if (!canInstallDownloadedPackages()) { return OtaDownloadStateMapper.waitingInstallPermission(fileName); } return OtaDownloadStateMapper.readyToInstall(fileName); @@ -580,9 +575,7 @@ public class AboutActivity extends BossScreenActivity { downloadLatestApk(); break; case OPEN_INSTALL_PERMISSION: - Intent intent = new Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES, Uri.parse("package:" + getPackageName())); - intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); - startActivity(intent); + openUnknownAppSourcesSettings(); break; case INSTALL_APK: installDownloadedApk(); @@ -598,11 +591,9 @@ public class AboutActivity extends BossScreenActivity { showMessage("当前没有可安装的更新包"); return; } - if (!getPackageManager().canRequestPackageInstalls()) { + if (!canInstallDownloadedPackages()) { showMessage("请先开启安装未知来源应用权限"); - Intent intent = new Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES, Uri.parse("package:" + getPackageName())); - intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); - startActivity(intent); + openUnknownAppSourcesSettings(); return; } Intent installIntent = new Intent(Intent.ACTION_VIEW); @@ -622,6 +613,19 @@ public class AboutActivity extends BossScreenActivity { return "boss-android-latest.apk"; } + private boolean canInstallDownloadedPackages() { + return Build.VERSION.SDK_INT < Build.VERSION_CODES.O + || getPackageManager().canRequestPackageInstalls(); + } + + private void openUnknownAppSourcesSettings() { + Intent intent = Build.VERSION.SDK_INT >= Build.VERSION_CODES.O + ? new Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES, Uri.parse("package:" + getPackageName())) + : new Intent(Settings.ACTION_SECURITY_SETTINGS); + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + startActivity(intent); + } + private void restoreDownloadUiState() { android.content.SharedPreferences prefs = getSharedPreferences(OTA_UI_PREFS, Context.MODE_PRIVATE); activeDownloadId = prefs.getLong(KEY_ACTIVE_DOWNLOAD_ID, -1L); diff --git a/android/app/src/main/java/com/hyzq/boss/AccessManagementActivity.java b/android/app/src/main/java/com/hyzq/boss/AccessManagementActivity.java new file mode 100644 index 0000000..56049f8 --- /dev/null +++ b/android/app/src/main/java/com/hyzq/boss/AccessManagementActivity.java @@ -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 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 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 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 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; + } +} diff --git a/android/app/src/main/java/com/hyzq/boss/AiAccountsActivity.java b/android/app/src/main/java/com/hyzq/boss/AiAccountsActivity.java index 5ad9925..6fe2d07 100644 --- a/android/app/src/main/java/com/hyzq/boss/AiAccountsActivity.java +++ b/android/app/src/main/java/com/hyzq/boss/AiAccountsActivity.java @@ -133,9 +133,7 @@ public class AiAccountsActivity extends BossScreenActivity { } else { appendContent(buildApiSection( isPrimaryRole(currentRole) ? "主要API配置" : "备用API配置", - isPrimaryRole(currentRole) - ? "主链路只在这里配置 OAuth 登录或 API 接入。" - : "主链路异常时自动切到这里,不抢占当前主控。", + isPrimaryRole(currentRole) ? "主链路" : "备用链路", accounts, currentRole )); @@ -170,7 +168,7 @@ public class AiAccountsActivity extends BossScreenActivity { if (currentRole == null) { return "主要API与备用API"; } - return "OAuth 登录与 API 接入"; + return "OAuth / API"; } private LinearLayout buildOverviewSection(@Nullable JSONArray accounts) { @@ -198,9 +196,9 @@ public class AiAccountsActivity extends BossScreenActivity { private String overviewSummaryForRole(@Nullable JSONArray accounts, String targetRole) { int count = countAccountsForRole(accounts, targetRole); if (count <= 0) { - return "暂未配置,点进去添加。"; + return "暂未配置"; } - return "已配置 " + count + " 条,点进去查看和编辑。"; + return "已配置 " + count + " 条"; } private int countAccountsForRole(@Nullable JSONArray accounts, String targetRole) { @@ -282,7 +280,7 @@ public class AiAccountsActivity extends BossScreenActivity { currentFastModelOverride, currentDeepModelOverride ), - "切换后会和主Agent对话框保持同步。", + "与主Agent对话同步", "切换", v -> showMasterAgentModePicker() )); @@ -290,7 +288,7 @@ public class AiAccountsActivity extends BossScreenActivity { this, "快速反应模型", "当前:" + MasterAgentModePresets.resolveFastModel(currentFastModelOverride), - "快速问答默认使用低推理强度。", + "低推理强度", "配置", v -> showMasterAgentModeModelPicker(true) )); @@ -298,7 +296,7 @@ public class AiAccountsActivity extends BossScreenActivity { this, "深度思考模型", "当前:" + MasterAgentModePresets.resolveDeepModel(currentDeepModelOverride), - "复杂任务默认使用高推理强度。", + "高推理强度", "配置", v -> showMasterAgentModeModelPicker(false) )); @@ -306,7 +304,7 @@ public class AiAccountsActivity extends BossScreenActivity { section.addView(BossUi.buildWechatMenuRow( this, "OAuth 登录", - isPrimaryRole(targetRole) ? "设置主要 OAuth 登录。" : "设置备用 OAuth 登录。", + isPrimaryRole(targetRole) ? "主要 OAuth" : "备用 OAuth", configuredMethodAccountsSummary(accounts, targetRole, true), null, v -> openRoleProviderChooser(targetRole, true) @@ -314,7 +312,7 @@ public class AiAccountsActivity extends BossScreenActivity { section.addView(BossUi.buildWechatMenuRow( this, "API 接入", - isPrimaryRole(targetRole) ? "设置主要 API 接入。" : "设置备用 API 接入。", + isPrimaryRole(targetRole) ? "主要 API" : "备用 API", configuredMethodAccountsSummary(accounts, targetRole, false), null, v -> openRoleProviderChooser(targetRole, false) diff --git a/android/app/src/main/java/com/hyzq/boss/BossApiClient.java b/android/app/src/main/java/com/hyzq/boss/BossApiClient.java index fe1736c..cd3aa43 100644 --- a/android/app/src/main/java/com/hyzq/boss/BossApiClient.java +++ b/android/app/src/main/java/com/hyzq/boss/BossApiClient.java @@ -66,6 +66,53 @@ public class BossApiClient { return response; } + public ApiResponse loginWithPassword(String account, String password) throws IOException, JSONException { + JSONObject body = new JSONObject(); + body.put("account", account); + body.put("password", password); + body.put("method", "password"); + ApiResponse response = request("POST", "/api/auth/login", body, false); + if (response.ok()) { + rememberIdentity(response.json); + } + return response; + } + + public ApiResponse sendVerificationCode(String account, String purpose) throws IOException, JSONException { + JSONObject body = new JSONObject(); + body.put("account", account); + body.put("purpose", purpose); + return request("POST", "/api/auth/send-code", body, false); + } + + public ApiResponse registerAccount( + String account, + String password, + String confirmPassword, + String code + ) throws IOException, JSONException { + JSONObject body = new JSONObject(); + body.put("account", account); + body.put("password", password); + body.put("confirmPassword", confirmPassword); + body.put("code", code); + return request("POST", "/api/auth/register", body, false); + } + + public ApiResponse resetPassword( + String account, + String password, + String confirmPassword, + String code + ) throws IOException, JSONException { + JSONObject body = new JSONObject(); + body.put("account", account); + body.put("password", password); + body.put("confirmPassword", confirmPassword); + body.put("code", code); + return request("POST", "/api/auth/forgot-password", body, false); + } + public ApiResponse restoreSession() throws IOException, JSONException { if (getRestoreToken().isEmpty()) { return ApiResponse.error(401, new JSONObject().put("ok", false).put("message", "NO_RESTORE_TOKEN")); @@ -83,6 +130,17 @@ public class BossApiClient { return request("GET", "/api/auth/session", null, true); } + public ApiResponse getAuthSessions() throws IOException, JSONException { + return requestWithRestore("GET", "/api/v1/auth/sessions", null); + } + + public ApiResponse revokeAuthSession(String sessionId) throws IOException, JSONException { + JSONObject payload = new JSONObject(); + payload.put("action", "revoke_session"); + payload.put("sessionId", sessionId); + return requestWithRestore("POST", "/api/v1/auth/sessions", payload); + } + public ApiResponse getConversations() throws IOException, JSONException { return requestWithRestoreRaw( "GET", @@ -107,6 +165,12 @@ public class BossApiClient { return requestWithRestore("GET", "/api/v1/conversation-folders/" + encode(folderKey), null); } + public ApiResponse markConversationRead(String projectId) throws IOException, JSONException { + JSONObject payload = new JSONObject(); + payload.put("action", "mark_read"); + return requestWithRestore("POST", "/api/v1/conversations/" + encode(projectId) + "/actions", payload); + } + public ApiResponse getProjectDetail(String projectId) throws IOException, JSONException { return requestWithRestore("GET", "/api/v1/projects/" + encode(projectId), null); } @@ -254,6 +318,16 @@ public class BossApiClient { ); } + public ApiResponse decideDialogGuardIntervention(String interventionId, String decision) throws IOException, JSONException { + JSONObject payload = new JSONObject(); + payload.put("decision", decision); + return requestWithRestore( + "POST", + "/api/v1/dialog-guard/interventions/" + encode(interventionId) + "/decision", + payload + ); + } + public ApiResponse retryDispatchPlan(String projectId, String planId) throws IOException, JSONException { return requestWithRestoreRaw( "POST", @@ -299,6 +373,18 @@ public class BossApiClient { return requestWithRestore("PATCH", "/api/v1/projects/" + encode(projectId) + "/dispatch-reminder", payload); } + public ApiResponse getAttachmentStorageConfig() throws IOException, JSONException { + return requestWithRestore("GET", "/api/v1/storage/config", null); + } + + public ApiResponse saveAttachmentStorageConfig(JSONObject payload) throws IOException, JSONException { + return requestWithRestore("PATCH", "/api/v1/storage/config", payload); + } + + public ApiResponse validateAttachmentStorageConfig(JSONObject payload) throws IOException, JSONException { + return requestWithRestore("POST", "/api/v1/storage/config/validate", payload); + } + public ApiResponse sendProjectMessage(String projectId, String body, String kind) throws IOException, JSONException { JSONObject payload = new JSONObject(); payload.put("body", body); @@ -312,6 +398,14 @@ public class BossApiClient { ); } + public ApiResponse deleteProjectMessage(String projectId, String messageId) throws IOException, JSONException { + return requestWithRestore( + "DELETE", + "/api/v1/projects/" + encode(projectId) + "/messages?messageId=" + encode(messageId), + null + ); + } + public ApiResponse uploadAttachment( String projectId, String fileName, @@ -484,6 +578,14 @@ public class BossApiClient { return requestWithRestore("GET", "/api/v1/accounts", null); } + public ApiResponse getAdminAccess() throws IOException, JSONException { + return requestWithRestore("GET", "/api/v1/admin/access", null); + } + + public ApiResponse updateAdminAccess(JSONObject payload) throws IOException, JSONException { + return requestWithRestore("POST", "/api/v1/admin/access", payload); + } + public ApiResponse createAccount(JSONObject payload) throws IOException, JSONException { return requestWithRestore("POST", "/api/v1/accounts", payload); } @@ -546,6 +648,14 @@ public class BossApiClient { return requestWithRestore("POST", "/api/v1/settings", payload); } + public ApiResponse getTelegramIntegration() throws IOException, JSONException { + return requestWithRestore("GET", "/api/v1/integrations/telegram", null); + } + + public ApiResponse updateTelegramIntegration(JSONObject payload) throws IOException, JSONException { + return requestWithRestore("POST", "/api/v1/integrations/telegram", payload); + } + public ApiResponse getOtaStatus() throws IOException, JSONException { return requestWithRestore("GET", "/api/v1/user/ota", null); } @@ -569,7 +679,7 @@ public class BossApiClient { } public String getAccountLabel() { - return prefs.getString(KEY_ACCOUNT, "17600003315"); + return prefs.getString(KEY_ACCOUNT, "krisolo"); } public String getDisplayName() { @@ -614,9 +724,9 @@ public class BossApiClient { int readTimeoutMs ) throws IOException, JSONException { ApiResponse response = requestRaw(method, path, body, true, connectTimeoutMs, readTimeoutMs); - if (response.statusCode == 401 && !getRestoreToken().isEmpty()) { - ApiResponse restored = restoreSession(); - if (restored.ok()) { + if (response.statusCode == 401) { + ApiResponse recovered = !getRestoreToken().isEmpty() ? restoreSession() : autoLogin(); + if (recovered.ok()) { return requestRaw(method, path, body, true, connectTimeoutMs, readTimeoutMs); } } @@ -709,7 +819,16 @@ public class BossApiClient { private ApiResponse executeConnection(HttpURLConnection connection, boolean expectProtected) throws IOException, JSONException { int statusCode = connection.getResponseCode(); captureSessionCookie(connection.getHeaderFields()); - JSONObject json = readJson(statusCode >= 400 ? connection.getErrorStream() : connection.getInputStream()); + JsonBody jsonBody = readJsonBody(statusCode >= 400 ? connection.getErrorStream() : connection.getInputStream()); + JSONObject json = jsonBody.json; + if (!jsonBody.validJson) { + int normalizedStatusCode = expectProtected && statusCode < 400 ? 401 : statusCode; + json = new JSONObject() + .put("ok", false) + .put("message", "NON_JSON_RESPONSE") + .put("statusCode", statusCode); + statusCode = normalizedStatusCode; + } if (statusCode == 401 && !expectProtected) { clearSession(); @@ -822,8 +941,12 @@ public class BossApiClient { } private JSONObject readJson(InputStream stream) throws IOException, JSONException { + return readJsonBody(stream).json; + } + + private JsonBody readJsonBody(InputStream stream) throws IOException, JSONException { if (stream == null) { - return new JSONObject(); + return new JsonBody(new JSONObject(), true); } StringBuilder builder = new StringBuilder(); try (BufferedReader reader = new BufferedReader(new InputStreamReader(stream, StandardCharsets.UTF_8))) { @@ -834,9 +957,13 @@ public class BossApiClient { } String raw = builder.toString().trim(); if (raw.isEmpty()) { - return new JSONObject(); + return new JsonBody(new JSONObject(), true); + } + try { + return new JsonBody(new JSONObject(raw), true); + } catch (JSONException error) { + return new JsonBody(new JSONObject(), false); } - return new JSONObject(raw); } private String readText(InputStream stream) throws IOException { @@ -870,9 +997,13 @@ public class BossApiClient { private void captureSessionCookie(Map> headers) { if (headers == null) return; - List setCookieHeaders = headers.get("Set-Cookie"); - if (setCookieHeaders == null) { - setCookieHeaders = headers.get("set-cookie"); + List setCookieHeaders = null; + for (Map.Entry> entry : headers.entrySet()) { + String headerName = entry.getKey(); + if (headerName != null && "set-cookie".equalsIgnoreCase(headerName)) { + setCookieHeaders = entry.getValue(); + break; + } } if (setCookieHeaders == null) return; @@ -965,6 +1096,16 @@ public class BossApiClient { } } + private static class JsonBody { + final JSONObject json; + final boolean validJson; + + JsonBody(JSONObject json, boolean validJson) { + this.json = json; + this.validJson = validJson; + } + } + public static class DownloadedAttachment { public final int statusCode; public final String fileName; diff --git a/android/app/src/main/java/com/hyzq/boss/BossAppVisibilityTracker.java b/android/app/src/main/java/com/hyzq/boss/BossAppVisibilityTracker.java new file mode 100644 index 0000000..0f0089f --- /dev/null +++ b/android/app/src/main/java/com/hyzq/boss/BossAppVisibilityTracker.java @@ -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; + } +} diff --git a/android/app/src/main/java/com/hyzq/boss/BossApplication.java b/android/app/src/main/java/com/hyzq/boss/BossApplication.java index 418e1e3..2c14b9e 100644 --- a/android/app/src/main/java/com/hyzq/boss/BossApplication.java +++ b/android/app/src/main/java/com/hyzq/boss/BossApplication.java @@ -1,13 +1,68 @@ package com.hyzq.boss; +import android.app.Activity; import android.app.Application; +import android.os.Bundle; import androidx.appcompat.app.AppCompatDelegate; public final class BossApplication extends Application { + private final BossAppVisibilityTracker visibilityTracker = new BossAppVisibilityTracker(); + private BossNotificationRouter notificationRouter; + private int startedActivityCount; + @Override public void onCreate() { AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_NO); super.onCreate(); + notificationRouter = new BossNotificationRouter(this, visibilityTracker); + registerActivityLifecycleCallbacks(new ActivityLifecycleCallbacks() { + @Override + public void onActivityCreated(Activity activity, Bundle savedInstanceState) {} + + @Override + public void onActivityStarted(Activity activity) { + startedActivityCount += 1; + if (startedActivityCount == 1) { + visibilityTracker.onAppForegrounded(); + BossBackgroundRealtimeService.stop(BossApplication.this); + } + } + + @Override + public void onActivityResumed(Activity activity) {} + + @Override + public void onActivityPaused(Activity activity) {} + + @Override + public void onActivityStopped(Activity activity) { + startedActivityCount = Math.max(0, startedActivityCount - 1); + if (startedActivityCount == 0) { + visibilityTracker.onAppBackgrounded(); + BossBackgroundRealtimeService.start(BossApplication.this); + } + } + + @Override + public void onActivitySaveInstanceState(Activity activity, Bundle outState) {} + + @Override + public void onActivityDestroyed(Activity activity) {} + }); + } + + @Override + public void onTerminate() { + BossBackgroundRealtimeService.stop(this); + super.onTerminate(); + } + + BossAppVisibilityTracker visibilityTracker() { + return visibilityTracker; + } + + BossNotificationRouter notificationRouter() { + return notificationRouter; } } diff --git a/android/app/src/main/java/com/hyzq/boss/BossBackgroundRealtimeService.java b/android/app/src/main/java/com/hyzq/boss/BossBackgroundRealtimeService.java new file mode 100644 index 0000000..f89e25e --- /dev/null +++ b/android/app/src/main/java/com/hyzq/boss/BossBackgroundRealtimeService.java @@ -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); + } +} diff --git a/android/app/src/main/java/com/hyzq/boss/BossMarkdown.java b/android/app/src/main/java/com/hyzq/boss/BossMarkdown.java index 57729cc..3f9a729 100644 --- a/android/app/src/main/java/com/hyzq/boss/BossMarkdown.java +++ b/android/app/src/main/java/com/hyzq/boss/BossMarkdown.java @@ -4,6 +4,7 @@ import android.content.Context; import android.content.res.Configuration; import android.graphics.Color; import android.graphics.Typeface; +import android.os.Build; import android.text.SpannableStringBuilder; import android.text.Spanned; import android.text.SpannedString; @@ -155,8 +156,10 @@ public final class BossMarkdown { ensureBlockSeparation(builder, false); int start = builder.length(); appendInlineStyled(builder, TextUtils.isEmpty(text) ? "引用" : text, palette); - builder.setSpan(new QuoteSpan(palette.quoteColor, BossUi.dp(palette.context, 3), BossUi.dp(palette.context, 8)), - start, builder.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + QuoteSpan quoteSpan = Build.VERSION.SDK_INT >= Build.VERSION_CODES.P + ? new QuoteSpan(palette.quoteColor, BossUi.dp(palette.context, 3), BossUi.dp(palette.context, 8)) + : new QuoteSpan(palette.quoteColor); + builder.setSpan(quoteSpan, start, builder.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); builder.append('\n'); } diff --git a/android/app/src/main/java/com/hyzq/boss/BossNotificationRouter.java b/android/app/src/main/java/com/hyzq/boss/BossNotificationRouter.java new file mode 100644 index 0000000..7242855 --- /dev/null +++ b/android/app/src/main/java/com/hyzq/boss/BossNotificationRouter.java @@ -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(); + } + } +} diff --git a/android/app/src/main/java/com/hyzq/boss/BossUi.java b/android/app/src/main/java/com/hyzq/boss/BossUi.java index bc1b4ce..0ecc546 100644 --- a/android/app/src/main/java/com/hyzq/boss/BossUi.java +++ b/android/app/src/main/java/com/hyzq/boss/BossUi.java @@ -1,5 +1,6 @@ package com.hyzq.boss; +import android.annotation.SuppressLint; import android.content.ClipData; import android.content.ClipboardManager; import android.content.Context; @@ -25,9 +26,15 @@ import android.widget.TextView; import android.widget.Toast; import androidx.annotation.Nullable; +import androidx.appcompat.widget.SwitchCompat; import androidx.core.widget.ImageViewCompat; import android.content.res.ColorStateList; +import org.json.JSONArray; +import org.json.JSONObject; + +import java.util.Locale; + public final class BossUi { private static final int[] AVATAR_BG_COLORS = { Color.parseColor("#1EC76F"), @@ -371,7 +378,64 @@ public final class BossUi { LinearLayout row = buildListRow(context, title, subtitle, meta, badge, listener); row.setBackgroundColor(Color.WHITE); row.setElevation(0f); + row.setPadding(dp(context, 16), dp(context, 13), dp(context, 16), dp(context, 13)); + return row; + } + + public static LinearLayout buildWechatSwitchRow( + Context context, + String title, + @Nullable String subtitle, + SwitchCompat switchView + ) { + LinearLayout row = new LinearLayout(context); + row.setOrientation(LinearLayout.HORIZONTAL); + row.setGravity(Gravity.CENTER_VERTICAL); + LinearLayout.LayoutParams params = new LinearLayout.LayoutParams( + LinearLayout.LayoutParams.MATCH_PARENT, + LinearLayout.LayoutParams.WRAP_CONTENT + ); + row.setLayoutParams(params); + row.setBackgroundColor(Color.WHITE); row.setPadding(dp(context, 18), dp(context, 15), dp(context, 18), dp(context, 15)); + + LinearLayout textWrap = new LinearLayout(context); + textWrap.setOrientation(LinearLayout.VERTICAL); + textWrap.setLayoutParams(new LinearLayout.LayoutParams( + 0, + LinearLayout.LayoutParams.WRAP_CONTENT, + 1f + )); + + TextView titleView = new TextView(context); + titleView.setText(title); + titleView.setTextSize(17); + titleView.setTypeface(Typeface.DEFAULT_BOLD); + titleView.setTextColor(context.getColor(R.color.boss_text_primary)); + textWrap.addView(titleView); + + if (!TextUtils.isEmpty(subtitle)) { + TextView subtitleView = new TextView(context); + subtitleView.setText(subtitle); + subtitleView.setTextSize(14); + subtitleView.setTextColor(context.getColor(R.color.boss_text_muted)); + subtitleView.setPadding(0, dp(context, 4), 0, 0); + textWrap.addView(subtitleView); + } + + row.addView(textWrap); + + ViewParent currentParent = switchView.getParent(); + if (currentParent instanceof ViewGroup) { + ((ViewGroup) currentParent).removeView(switchView); + } + LinearLayout.LayoutParams switchParams = new LinearLayout.LayoutParams( + LinearLayout.LayoutParams.WRAP_CONTENT, + LinearLayout.LayoutParams.WRAP_CONTENT + ); + switchParams.leftMargin = dp(context, 12); + switchView.setLayoutParams(switchParams); + row.addView(switchView); return row; } @@ -501,11 +565,11 @@ public final class BossUi { ); params.leftMargin = dp(context, 12); params.rightMargin = dp(context, 12); - params.bottomMargin = dp(context, 12); + params.bottomMargin = dp(context, 1); card.setLayoutParams(params); - card.setPadding(dp(context, 14), dp(context, 14), dp(context, 14), dp(context, 14)); - card.setBackground(createRoundedBackground(Color.WHITE, dp(context, 18))); - card.setElevation(dp(context, 1)); + card.setPadding(dp(context, 14), dp(context, 13), dp(context, 14), dp(context, 13)); + card.setBackgroundColor(Color.WHITE); + card.setElevation(0f); if (listener != null) { card.setClickable(true); card.setFocusable(true); @@ -944,7 +1008,34 @@ public final class BossUi { } public static LinearLayout buildEmptyCard(Context context, String text) { - return buildCard(context, "暂无内容", text, "下拉或点击顶部刷新按钮重试。"); + LinearLayout card = new LinearLayout(context); + card.setOrientation(LinearLayout.VERTICAL); + LinearLayout.LayoutParams params = new LinearLayout.LayoutParams( + LinearLayout.LayoutParams.MATCH_PARENT, + LinearLayout.LayoutParams.WRAP_CONTENT + ); + params.leftMargin = dp(context, 16); + params.rightMargin = dp(context, 16); + params.topMargin = dp(context, 28); + card.setLayoutParams(params); + card.setGravity(Gravity.CENTER_HORIZONTAL); + card.setPadding(dp(context, 16), dp(context, 18), dp(context, 16), dp(context, 18)); + + TextView titleView = new TextView(context); + titleView.setText("暂无内容"); + titleView.setTextSize(16); + titleView.setTypeface(Typeface.DEFAULT_BOLD); + titleView.setTextColor(context.getColor(R.color.boss_text_primary)); + card.addView(titleView); + + TextView bodyView = new TextView(context); + bodyView.setText(text); + bodyView.setTextSize(13); + bodyView.setGravity(Gravity.CENTER); + bodyView.setTextColor(context.getColor(R.color.boss_text_muted)); + bodyView.setPadding(0, dp(context, 8), 0, 0); + card.addView(bodyView); + return card; } public static TextView buildHintPill(Context context, String text) { @@ -965,6 +1056,7 @@ public final class BossUi { return pill; } + @SuppressLint("WrongConstant") public static LinearLayout buildMessageBubble( Context context, String senderLabel, @@ -1006,6 +1098,410 @@ public final class BossUi { return wrapper; } + public static LinearLayout buildMasterAgentMessageBubble( + Context context, + String senderLabel, + String body, + @Nullable String meta, + @Nullable String kindLabel + ) { + LinearLayout wrapper = buildMessageBubble(context, senderLabel, body, meta, false, kindLabel); + View bubble = findMessageBodyContainer(wrapper); + if (bubble != null) { + GradientDrawable background = createRoundedBackground(Color.parseColor("#EAF5FF"), dp(context, 18)); + background.setStroke(dp(context, 1), Color.parseColor("#D1E8FF")); + bubble.setBackground(background); + } + return wrapper; + } + + public static LinearLayout buildThreadProcessFoldCard( + Context context, + int itemCount, + @Nullable String preview, + @Nullable String detail + ) { + LinearLayout card = new LinearLayout(context); + card.setOrientation(LinearLayout.VERTICAL); + LinearLayout.LayoutParams params = new LinearLayout.LayoutParams( + LinearLayout.LayoutParams.MATCH_PARENT, + LinearLayout.LayoutParams.WRAP_CONTENT + ); + params.leftMargin = dp(context, 12); + params.rightMargin = dp(context, 12); + params.bottomMargin = dp(context, 10); + card.setLayoutParams(params); + card.setPadding(dp(context, 14), dp(context, 12), dp(context, 14), dp(context, 12)); + GradientDrawable background = createRoundedBackground(Color.parseColor("#F7F8F7"), dp(context, 16)); + background.setStroke(dp(context, 1), Color.parseColor("#E4E9E5")); + card.setBackground(background); + card.setClickable(true); + card.setFocusable(true); + + LinearLayout header = new LinearLayout(context); + header.setOrientation(LinearLayout.HORIZONTAL); + header.setGravity(Gravity.CENTER_VERTICAL); + header.setLayoutParams(new LinearLayout.LayoutParams( + LinearLayout.LayoutParams.MATCH_PARENT, + LinearLayout.LayoutParams.WRAP_CONTENT + )); + + TextView titleView = new TextView(context); + titleView.setTextSize(14); + titleView.setTypeface(Typeface.DEFAULT_BOLD); + titleView.setTextColor(context.getColor(R.color.boss_green)); + titleView.setLayoutParams(new LinearLayout.LayoutParams( + 0, + LinearLayout.LayoutParams.WRAP_CONTENT, + 1f + )); + header.addView(titleView); + + TextView arrowView = new TextView(context); + arrowView.setTextSize(16); + arrowView.setTypeface(Typeface.DEFAULT_BOLD); + arrowView.setTextColor(context.getColor(R.color.boss_text_muted)); + arrowView.setPadding(dp(context, 8), 0, 0, 0); + header.addView(arrowView); + card.addView(header); + + TextView previewView = new TextView(context); + previewView.setText(TextUtils.isEmpty(preview) ? "线程正在输出过程内容" : preview); + previewView.setTextSize(13); + previewView.setLineSpacing(0f, 1.24f); + previewView.setTextColor(context.getColor(R.color.boss_text_muted)); + previewView.setPadding(0, dp(context, 6), 0, 0); + previewView.setMaxLines(2); + previewView.setEllipsize(TextUtils.TruncateAt.END); + card.addView(previewView); + + TextView detailView = new TextView(context); + detailView.setText(detail); + detailView.setTextSize(13); + detailView.setLineSpacing(0f, 1.28f); + detailView.setTextColor(context.getColor(R.color.boss_text_primary)); + detailView.setPadding(0, dp(context, 10), 0, 0); + detailView.setVisibility(View.GONE); + card.addView(detailView); + + final boolean[] expanded = new boolean[] {false}; + View.OnClickListener toggleListener = ignored -> { + expanded[0] = !expanded[0]; + titleView.setText((expanded[0] ? "收起本轮工作过程" : "查看本轮工作过程") + "(" + itemCount + " 条)"); + arrowView.setText(expanded[0] ? "⌃" : "⌄"); + detailView.setVisibility(expanded[0] ? View.VISIBLE : View.GONE); + previewView.setVisibility(expanded[0] ? View.GONE : View.VISIBLE); + card.setContentDescription((expanded[0] ? "已展开" : "已折叠") + "本轮工作过程"); + }; + card.setOnClickListener(toggleListener); + titleView.setText("查看本轮工作过程(" + itemCount + " 条)"); + arrowView.setText("⌄"); + card.setContentDescription("已折叠本轮工作过程"); + return card; + } + + public static LinearLayout buildControlSummaryCard( + Context context, + String title, + String body, + @Nullable String meta, + @Nullable String badge + ) { + LinearLayout card = buildCard(context, title, body, meta, null); + card.setPadding(dp(context, 16), dp(context, 14), dp(context, 16), dp(context, 14)); + card.setBackground(createRoundedBackground(Color.parseColor("#F7FAF7"), dp(context, 18))); + GradientDrawable background = (GradientDrawable) card.getBackground(); + background.setStroke(dp(context, 1), Color.parseColor("#DCEBDD")); + if (!TextUtils.isEmpty(badge)) { + View badgeView = buildHintPill(context, badge); + if (badgeView.getParent() != null) { + ((ViewGroup) badgeView.getParent()).removeView(badgeView); + } + card.addView(badgeView, 0); + } + return card; + } + + public static LinearLayout buildExecutionProgressCard( + Context context, + @Nullable JSONObject progress, + @Nullable String meta + ) { + LinearLayout card = new LinearLayout(context); + card.setOrientation(LinearLayout.VERTICAL); + LinearLayout.LayoutParams params = new LinearLayout.LayoutParams( + LinearLayout.LayoutParams.MATCH_PARENT, + LinearLayout.LayoutParams.WRAP_CONTENT + ); + params.leftMargin = dp(context, 12); + params.rightMargin = dp(context, 12); + params.bottomMargin = dp(context, 12); + card.setLayoutParams(params); + card.setPadding(dp(context, 18), dp(context, 16), dp(context, 18), dp(context, 16)); + GradientDrawable background = createRoundedBackground(Color.WHITE, dp(context, 22)); + background.setStroke(dp(context, 1), Color.parseColor("#E5E9E7")); + card.setBackground(background); + card.setElevation(dp(context, 1)); + + LinearLayout titleRow = new LinearLayout(context); + titleRow.setOrientation(LinearLayout.HORIZONTAL); + titleRow.setGravity(Gravity.CENTER_VERTICAL); + titleRow.setLayoutParams(new LinearLayout.LayoutParams( + LinearLayout.LayoutParams.MATCH_PARENT, + LinearLayout.LayoutParams.WRAP_CONTENT + )); + TextView title = sectionTitle(context, "进度"); + title.setLayoutParams(new LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f)); + titleRow.addView(title); + TextView pin = new TextView(context); + pin.setText("◆"); + pin.setTextSize(18); + pin.setTextColor(Color.parseColor("#9AA09D")); + titleRow.addView(pin); + card.addView(titleRow); + + JSONArray steps = progress == null ? null : progress.optJSONArray("steps"); + if (steps == null || steps.length() == 0) { + card.addView(progressLine(context, "等待执行进度回写", "running")); + } else { + for (int i = 0; i < steps.length(); i += 1) { + JSONObject step = steps.optJSONObject(i); + if (step == null) { + continue; + } + String text = step.optString("text", "").trim(); + if (TextUtils.isEmpty(text)) { + continue; + } + card.addView(progressLine(context, text, step.optString("status", "pending"))); + } + } + + card.addView(divider(context)); + card.addView(sectionTitle(context, "分支详情")); + JSONObject branch = progress == null ? null : progress.optJSONObject("branch"); + if (branch != null) { + String changeText = formatChangeText(branch); + if (!TextUtils.isEmpty(changeText)) { + card.addView(changeRow(context, branch)); + } + String gitStatus = branch.optString("gitStatus", "").trim(); + card.addView(detailRow(context, "⌘", TextUtils.isEmpty(gitStatus) ? "Git 操作" : gitStatus, "", false)); + String ghStatus = branch.optString("githubCliStatus", "").trim(); + if ("unavailable".equals(ghStatus)) { + card.addView(detailRow(context, "○", "GitHub CLI 不可用", "", false, true)); + } else if ("available".equals(ghStatus)) { + card.addView(detailRow(context, "○", "GitHub CLI 可用", "", false)); + } + } else { + card.addView(detailRow(context, "⌘", "Git 操作", "等待执行器回写", false)); + } + + JSONArray artifacts = progress == null ? null : progress.optJSONArray("artifacts"); + if (artifacts != null && artifacts.length() > 0) { + card.addView(divider(context)); + card.addView(sectionTitle(context, "生成结果")); + int shown = 0; + for (int i = 0; i < artifacts.length(); i += 1) { + JSONObject artifact = artifacts.optJSONObject(i); + String label = artifact == null ? "" : artifact.optString("label", "").trim(); + if (TextUtils.isEmpty(label)) { + continue; + } + String icon = "image".equals(artifact.optString("kind", "")) ? "◌" : "▣"; + card.addView(detailRow(context, icon, label, "", false)); + shown += 1; + if (shown >= 6) { + break; + } + } + if (artifacts.length() > shown) { + card.addView(detailRow(context, "", "再显示 " + (artifacts.length() - shown) + " 个", "", false, true)); + } + } + + JSONArray agents = progress == null ? null : progress.optJSONArray("agents"); + if (agents != null && agents.length() > 0) { + card.addView(divider(context)); + card.addView(sectionTitle(context, "后台智能体")); + for (int i = 0; i < agents.length(); i += 1) { + JSONObject agent = agents.optJSONObject(i); + String name = agent == null ? "" : agent.optString("name", "").trim(); + if (TextUtils.isEmpty(name)) { + continue; + } + String role = agent.optString("role", "").trim(); + card.addView(detailRow( + context, + "♙", + TextUtils.isEmpty(role) ? name : name + "(" + role + ")", + "", + false + )); + } + } + + if (!TextUtils.isEmpty(meta)) { + TextView metaView = secondaryText(context, meta); + metaView.setPadding(0, dp(context, 10), 0, 0); + card.addView(metaView); + } + return card; + } + + private static View progressLine(Context context, String text, String status) { + LinearLayout row = new LinearLayout(context); + row.setOrientation(LinearLayout.HORIZONTAL); + row.setGravity(Gravity.TOP); + row.setPadding(0, dp(context, 10), 0, 0); + + TextView check = new TextView(context); + LinearLayout.LayoutParams checkParams = new LinearLayout.LayoutParams(dp(context, 28), dp(context, 28)); + check.setLayoutParams(checkParams); + check.setGravity(Gravity.CENTER); + check.setText("✓"); + check.setTextSize(15); + check.setTypeface(Typeface.DEFAULT_BOLD); + int color = "failed".equals(status) ? Color.parseColor("#E44B4B") : + "running".equals(status) ? Color.parseColor("#1EC76F") : Color.parseColor("#9AA09D"); + check.setTextColor(Color.WHITE); + check.setBackground(createRoundedBackground(color, dp(context, 14))); + row.addView(check); + + TextView body = new TextView(context); + LinearLayout.LayoutParams bodyParams = new LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f); + bodyParams.leftMargin = dp(context, 12); + body.setLayoutParams(bodyParams); + body.setText(text); + body.setTextSize(18); + body.setLineSpacing(0f, 1.22f); + body.setTextColor(context.getColor(R.color.boss_text_primary)); + row.addView(body); + return row; + } + + private static View divider(Context context) { + View divider = new View(context); + LinearLayout.LayoutParams params = new LinearLayout.LayoutParams( + LinearLayout.LayoutParams.MATCH_PARENT, + dp(context, 1) + ); + params.topMargin = dp(context, 16); + params.bottomMargin = dp(context, 14); + divider.setLayoutParams(params); + divider.setBackgroundColor(Color.parseColor("#ECEFEE")); + return divider; + } + + private static TextView sectionTitle(Context context, String text) { + TextView view = new TextView(context); + view.setText(text); + view.setTextSize(17); + view.setTypeface(Typeface.DEFAULT_BOLD); + view.setTextColor(Color.parseColor("#8B918F")); + return view; + } + + private static TextView secondaryText(Context context, String text) { + TextView view = new TextView(context); + view.setText(text); + view.setTextSize(12); + view.setTextColor(context.getColor(R.color.boss_text_muted)); + return view; + } + + private static View detailRow(Context context, String iconText, String label, String value, boolean valueIsChange) { + return detailRow(context, iconText, label, value, valueIsChange, false); + } + + private static View detailRow(Context context, String iconText, String label, String value, boolean valueIsChange, boolean muted) { + LinearLayout row = new LinearLayout(context); + row.setOrientation(LinearLayout.HORIZONTAL); + row.setGravity(Gravity.CENTER_VERTICAL); + row.setPadding(0, dp(context, 8), 0, 0); + + TextView icon = new TextView(context); + icon.setText(iconText); + icon.setGravity(Gravity.CENTER); + icon.setTextSize(18); + icon.setTextColor(muted ? Color.parseColor("#9AA09D") : context.getColor(R.color.boss_text_primary)); + row.addView(icon, new LinearLayout.LayoutParams(dp(context, 34), LinearLayout.LayoutParams.WRAP_CONTENT)); + + TextView labelView = new TextView(context); + labelView.setText(label); + labelView.setTextSize(17); + labelView.setTextColor(muted ? Color.parseColor("#8B918F") : context.getColor(R.color.boss_text_primary)); + row.addView(labelView, new LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f)); + + if (!TextUtils.isEmpty(value)) { + TextView valueView = new TextView(context); + valueView.setText(value); + valueView.setTextSize(16); + valueView.setTextColor(valueIsChange ? Color.parseColor("#00A94F") : context.getColor(R.color.boss_text_muted)); + row.addView(valueView); + } + return row; + } + + private static View changeRow(Context context, JSONObject branch) { + LinearLayout row = new LinearLayout(context); + row.setOrientation(LinearLayout.HORIZONTAL); + row.setGravity(Gravity.CENTER_VERTICAL); + row.setPadding(0, dp(context, 8), 0, 0); + + TextView icon = new TextView(context); + icon.setText("⊞"); + icon.setGravity(Gravity.CENTER); + icon.setTextSize(18); + icon.setTextColor(context.getColor(R.color.boss_text_primary)); + row.addView(icon, new LinearLayout.LayoutParams(dp(context, 34), LinearLayout.LayoutParams.WRAP_CONTENT)); + + TextView labelView = new TextView(context); + labelView.setText("变更"); + labelView.setTextSize(17); + labelView.setTextColor(context.getColor(R.color.boss_text_primary)); + row.addView(labelView, new LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f)); + + int additions = branch.optInt("additions", 0); + if (additions > 0) { + TextView additionsView = new TextView(context); + additionsView.setText("+" + String.format(Locale.US, "%,d", additions)); + additionsView.setTextSize(16); + additionsView.setTextColor(Color.parseColor("#00A94F")); + row.addView(additionsView); + } + int deletions = branch.optInt("deletions", 0); + if (deletions > 0) { + TextView deletionsView = new TextView(context); + deletionsView.setText("-" + String.format(Locale.US, "%,d", deletions)); + deletionsView.setTextSize(16); + deletionsView.setTextColor(Color.parseColor("#C52828")); + LinearLayout.LayoutParams deletionParams = new LinearLayout.LayoutParams( + LinearLayout.LayoutParams.WRAP_CONTENT, + LinearLayout.LayoutParams.WRAP_CONTENT + ); + deletionParams.leftMargin = dp(context, 8); + row.addView(deletionsView, deletionParams); + } + return row; + } + + private static String formatChangeText(JSONObject branch) { + int additions = branch.optInt("additions", 0); + int deletions = branch.optInt("deletions", 0); + StringBuilder builder = new StringBuilder(); + if (additions > 0) { + builder.append("+").append(String.format(Locale.US, "%,d", additions)); + } + if (deletions > 0) { + if (builder.length() > 0) { + builder.append(" "); + } + builder.append("-").append(String.format(Locale.US, "%,d", deletions)); + } + return builder.toString(); + } + public static LinearLayout buildAttachmentMessageCard( Context context, String senderLabel, @@ -1160,6 +1656,23 @@ public final class BossUi { return wrapper; } + public static LinearLayout buildMasterAgentForwardSingleBubble( + Context context, + String senderLabel, + String body, + @Nullable String meta, + @Nullable String sourceLabel + ) { + LinearLayout wrapper = buildForwardSingleBubble(context, senderLabel, body, meta, sourceLabel, false); + View bubble = findMessageBodyContainer(wrapper); + if (bubble != null) { + GradientDrawable background = createRoundedBackground(Color.parseColor("#EAF5FF"), dp(context, 18)); + background.setStroke(dp(context, 1), Color.parseColor("#D1E8FF")); + bubble.setBackground(background); + } + return wrapper; + } + public static LinearLayout buildForwardBundleCard( Context context, String senderLabel, @@ -1235,6 +1748,14 @@ public final class BossUi { return buildMessageBubble(context, effectiveSender, body, "发送中", true, null); } + @Nullable + private static View findMessageBodyContainer(LinearLayout wrapper) { + if (wrapper == null || wrapper.getChildCount() < 2) { + return null; + } + return wrapper.getChildAt(1); + } + public static void applyMessageSelectionState(Context context, View messageView, boolean selected) { if (messageView == null) { return; @@ -1248,6 +1769,61 @@ public final class BossUi { } } + public static View wrapIncomingMessageWithSourceAvatar( + Context context, + View messageView, + @Nullable String avatarLabel, + @Nullable String sourceName + ) { + if (messageView == null || TextUtils.isEmpty(avatarLabel)) { + return messageView; + } + ViewParent parent = messageView.getParent(); + if (parent instanceof ViewGroup) { + ((ViewGroup) parent).removeView(messageView); + } + + LinearLayout row = new LinearLayout(context); + row.setOrientation(LinearLayout.HORIZONTAL); + row.setGravity(Gravity.TOP); + LinearLayout.LayoutParams rowParams = new LinearLayout.LayoutParams( + LinearLayout.LayoutParams.MATCH_PARENT, + LinearLayout.LayoutParams.WRAP_CONTENT + ); + row.setLayoutParams(rowParams); + + TextView avatar = buildAvatarCircle( + context, + avatarLabel, + Color.parseColor("#E5F6EC"), + context.getColor(R.color.boss_green), + 36 + ); + LinearLayout.LayoutParams avatarParams = new LinearLayout.LayoutParams(dp(context, 36), dp(context, 36)); + avatarParams.leftMargin = dp(context, 8); + avatarParams.rightMargin = dp(context, 8); + avatarParams.topMargin = dp(context, 16); + avatar.setLayoutParams(avatarParams); + if (!TextUtils.isEmpty(sourceName)) { + avatar.setContentDescription("来自 " + sourceName); + } + row.addView(avatar); + + LinearLayout.LayoutParams contentParams = new LinearLayout.LayoutParams( + 0, + LinearLayout.LayoutParams.WRAP_CONTENT, + 1f + ); + if (messageView.getLayoutParams() instanceof LinearLayout.LayoutParams) { + LinearLayout.LayoutParams previous = (LinearLayout.LayoutParams) messageView.getLayoutParams(); + contentParams.topMargin = previous.topMargin; + contentParams.bottomMargin = previous.bottomMargin; + } + messageView.setLayoutParams(contentParams); + row.addView(messageView); + return row; + } + public static TextView buildMessagePlaceholder(Context context, String text) { TextView placeholder = new TextView(context); LinearLayout.LayoutParams params = new LinearLayout.LayoutParams( @@ -1327,9 +1903,6 @@ public final class BossUi { @Nullable String meta, boolean outgoing ) { - if (outgoing && !TextUtils.isEmpty(meta)) { - return meta; - } if (TextUtils.isEmpty(meta)) { return senderLabel; } diff --git a/android/app/src/main/java/com/hyzq/boss/ConversationInfoActivity.java b/android/app/src/main/java/com/hyzq/boss/ConversationInfoActivity.java index b8efb73..1b0010a 100644 --- a/android/app/src/main/java/com/hyzq/boss/ConversationInfoActivity.java +++ b/android/app/src/main/java/com/hyzq/boss/ConversationInfoActivity.java @@ -18,6 +18,7 @@ import java.util.Map; public class ConversationInfoActivity extends BossScreenActivity { public static final String EXTRA_PROJECT_ID = "project_id"; public static final String EXTRA_PROJECT_NAME = "project_name"; + public static final String EXTRA_TAKEOVER_ENABLED = "takeover_enabled"; private static final long REALTIME_RELOAD_THROTTLE_MS = 900L; private String projectId; @@ -178,17 +179,9 @@ public class ConversationInfoActivity extends BossScreenActivity { takeoverInheritedFromGlobal = agentControls != null && agentControls.optBoolean("takeoverInheritedFromGlobal", false); configureScreen("会话信息", buildSubtitle(threadMeta, participantCount)); - appendContent(BossUi.buildSimpleProfileHeader( - this, - projectName, - "单线程会话", - buildHeaderDetail(project, threadMeta, participantCount) - )); - - appendThreadStatusSummary(threadStatusPayload); appendTakeoverControl(); - appendContent(BossUi.buildWechatMenuRow( + appendConversationInfoItem(BossUi.buildWechatMenuRow( this, "发起群聊", "选择其他线程加入新群", @@ -197,7 +190,7 @@ public class ConversationInfoActivity extends BossScreenActivity { v -> openGroupCreate() )); - appendContent(BossUi.buildWechatMenuRow( + appendConversationInfoItem(BossUi.buildWechatMenuRow( this, "线程详情", "查看当前线程聊天与项目", @@ -206,7 +199,7 @@ public class ConversationInfoActivity extends BossScreenActivity { v -> openProject(projectId, projectName) )); - appendContent(BossUi.buildWechatMenuRow( + appendConversationInfoItem(BossUi.buildWechatMenuRow( this, "线程状态", "状态文档和最近进展事件", @@ -215,7 +208,7 @@ public class ConversationInfoActivity extends BossScreenActivity { v -> openThreadStatus() )); - appendContent(BossUi.buildWechatMenuRow( + appendConversationInfoItem(BossUi.buildWechatMenuRow( this, "参与线程", participantCount <= 0 ? "暂无参与线程" : "共 " + participantCount + " 个", @@ -225,7 +218,7 @@ public class ConversationInfoActivity extends BossScreenActivity { )); if (participants == null || participants.length() == 0) { - appendContent(BossUi.buildWechatMenuRow( + appendConversationInfoItem(BossUi.buildWechatMenuRow( this, "暂无参与线程", "下拉刷新后重试", @@ -237,7 +230,7 @@ public class ConversationInfoActivity extends BossScreenActivity { for (int i = 0; i < participants.length(); i++) { JSONObject participant = participants.optJSONObject(i); if (participant == null) continue; - appendContent(buildParticipantRow(participant)); + appendConversationInfoItem(buildParticipantRow(participant)); } } @@ -246,86 +239,34 @@ public class ConversationInfoActivity extends BossScreenActivity { private void appendTakeoverControl() { SwitchCompat takeoverSwitch = new SwitchCompat(this); - takeoverSwitch.setText("开启"); + takeoverSwitch.setShowText(false); + takeoverSwitch.setText(null); takeoverSwitch.setChecked(takeoverEnabled); takeoverSwitch.setOnCheckedChangeListener((buttonView, isChecked) -> saveTakeoverSetting(isChecked)); - appendContent(BossUi.buildFormCell( + appendConversationInfoItem(BossUi.buildWechatSwitchRow( this, "主 Agent 协同接管", takeoverInheritedFromGlobal - ? "当前跟随全局默认开启。主 Agent 会协同推进,但不会抢走你直接控制线程开发的能力。" - : "为这个线程单独开启主 Agent 协同推进。不会抢走你直接控制线程开发的能力。", + ? "跟随全局默认开启" + : "为此线程单独开启", takeoverSwitch )); } - private void appendThreadStatusSummary(@Nullable JSONObject threadStatusPayload) { - if (threadStatusPayload == null) { - return; + private void appendConversationInfoItem(android.view.View view) { + android.view.ViewGroup.LayoutParams currentParams = view.getLayoutParams(); + LinearLayout.LayoutParams params; + if (currentParams instanceof LinearLayout.LayoutParams) { + params = (LinearLayout.LayoutParams) currentParams; + } else { + params = new LinearLayout.LayoutParams( + LinearLayout.LayoutParams.MATCH_PARENT, + LinearLayout.LayoutParams.WRAP_CONTENT + ); } - JSONObject document = threadStatusPayload.optJSONObject("threadStatusDocument"); - if (document == null) { - return; - } - JSONArray recentProgressEvents = threadStatusPayload.optJSONArray("recentProgressEvents"); - int eventCount = recentProgressEvents == null ? 0 : recentProgressEvents.length(); - String body = buildThreadStatusSummaryBody(document, eventCount); - String meta = buildThreadStatusSummaryMeta(document, eventCount); - appendContent(BossUi.buildCard(this, "线程状态摘要", body, meta)); - } - - private String buildThreadStatusSummaryBody(JSONObject document, int eventCount) { - return joinNonEmptyLines( - formatSummaryLine("当前目标", document.optString("projectGoal", "")), - formatSummaryLine("当前进度", document.optString("currentProgress", "")), - formatSummaryLine("当前阻塞", document.optString("currentBlockers", "")), - formatSummaryLine("建议下一步", document.optString("recommendedNextStep", "")), - eventCount > 0 ? "最近进展:" + eventCount + " 条" : "" - ); - } - - private String buildThreadStatusSummaryMeta(JSONObject document, int eventCount) { - return joinNonEmptyParts( - projectFolderName, - eventCount > 0 ? "最近 " + eventCount + " 条进展" : "暂无进展", - document.optString("updatedAt", "").isEmpty() ? "" : "更新于 " + document.optString("updatedAt", "") - ); - } - - private String formatSummaryLine(String label, String value) { - String trimmed = value == null ? "" : value.trim(); - if (trimmed.isEmpty()) { - return ""; - } - return label + ":" + trimmed; - } - - private String joinNonEmptyLines(String... values) { - StringBuilder builder = new StringBuilder(); - for (String value : values) { - if (value == null || value.trim().isEmpty()) { - continue; - } - if (builder.length() > 0) { - builder.append('\n'); - } - builder.append(value.trim()); - } - return builder.toString(); - } - - private String joinNonEmptyParts(String... values) { - StringBuilder builder = new StringBuilder(); - for (String value : values) { - if (value == null || value.trim().isEmpty()) { - continue; - } - if (builder.length() > 0) { - builder.append(" · "); - } - builder.append(value.trim()); - } - return builder.toString(); + params.bottomMargin = BossUi.dp(this, 8); + view.setLayoutParams(params); + appendContent(view); } private LinearLayout buildParticipantRow(JSONObject participant) { @@ -445,6 +386,10 @@ public class ConversationInfoActivity extends BossScreenActivity { throw new IllegalStateException(response.message()); } runOnUiThread(() -> { + Intent result = new Intent(); + result.putExtra(EXTRA_PROJECT_NAME, projectName); + result.putExtra(EXTRA_TAKEOVER_ENABLED, enabled); + setResult(RESULT_OK, result); showMessage(enabled ? "已开启主 Agent 协同接管" : "已关闭主 Agent 协同接管"); reload(); }); @@ -517,25 +462,6 @@ public class ConversationInfoActivity extends BossScreenActivity { return folder + " · " + suffix; } - private String buildHeaderDetail(JSONObject project, @Nullable JSONObject threadMeta, int count) { - StringBuilder builder = new StringBuilder(); - String threadId = resolveThreadId(project, threadMeta); - if (!threadId.isEmpty()) { - builder.append(threadId); - } - if (!projectFolderName.isEmpty()) { - if (builder.length() > 0) { - builder.append(" · "); - } - builder.append(projectFolderName); - } - if (builder.length() > 0) { - builder.append(" · "); - } - builder.append(count <= 0 ? "暂无参与线程" : count + " 个参与线程"); - return builder.toString(); - } - private String resolveThreadId(JSONObject project, @Nullable JSONObject threadMeta) { if (threadMeta != null) { String threadId = threadMeta.optString("threadId", ""); diff --git a/android/app/src/main/java/com/hyzq/boss/MainActivity.java b/android/app/src/main/java/com/hyzq/boss/MainActivity.java index 80e265f..af37dbd 100644 --- a/android/app/src/main/java/com/hyzq/boss/MainActivity.java +++ b/android/app/src/main/java/com/hyzq/boss/MainActivity.java @@ -1,11 +1,17 @@ package com.hyzq.boss; +import android.Manifest; +import android.content.res.ColorStateList; import android.content.Context; import android.content.Intent; +import android.content.pm.PackageManager; +import android.graphics.Color; +import android.os.Build; import android.os.Bundle; import android.os.Handler; import android.os.Looper; import android.text.Editable; +import android.text.TextUtils; import android.text.TextWatcher; import android.view.LayoutInflater; import android.view.View; @@ -23,6 +29,7 @@ import android.widget.ScrollView; import android.widget.TextView; import android.widget.Toast; +import androidx.activity.OnBackPressedCallback; import androidx.annotation.Nullable; import androidx.appcompat.app.AppCompatActivity; import androidx.recyclerview.widget.DiffUtil; @@ -45,14 +52,17 @@ import java.util.function.Supplier; public class MainActivity extends AppCompatActivity { public static final String EXTRA_INITIAL_TAB = "initial_tab"; + private static final int REQUEST_POST_NOTIFICATIONS = 2101; private static final String UI_PREFS = "boss_native_client"; private static final String KEY_LAST_ROOT_TAB = "last_root_tab"; + private static final String KEY_NOTIFICATION_PERMISSION_REQUESTED = "notification_permission_requested"; private static final long ROOT_BACK_EXIT_WINDOW_MS = 1_500L; private static final long CONVERSATION_AUTO_REFRESH_MS = 12_000L; private static final long REALTIME_REFRESH_DEBOUNCE_MS = 350L; private static final long REALTIME_REFRESH_THROTTLE_MS = 900L; private final ExecutorService executor = Executors.newSingleThreadExecutor(); + private final ExecutorService sessionExecutor = Executors.newSingleThreadExecutor(); private final Handler uiHandler = new Handler(Looper.getMainLooper()); private BossApiClient apiClient; @@ -64,7 +74,16 @@ public class MainActivity extends AppCompatActivity { private View mainTopBar; private TextView loginTitle; private TextView loginHint; + private EditText loginAccountInput; + private EditText loginPasswordInput; + private EditText loginConfirmPasswordInput; + private EditText loginCodeInput; + private View loginCodeRow; + private Button loginSendCodeButton; private Button loginButton; + private Button loginModeButton; + private Button registerModeButton; + private Button forgotModeButton; private ProgressBar loginProgress; private ImageButton backButton; @@ -92,6 +111,7 @@ public class MainActivity extends AppCompatActivity { private String activeTab = "conversations"; private String preferredEntryTab = "conversations"; private @Nullable String requestedInitialTab; + private String authMode = "login"; private boolean userSelectedTab = false; private long lastRootBackPressedAt = 0L; private @Nullable JSONObject sessionData; @@ -107,9 +127,11 @@ public class MainActivity extends AppCompatActivity { private boolean conversationQuickActionsVisible = false; private boolean conversationAutoRefreshArmed = false; private boolean conversationAutoRefreshEnabled = false; + private boolean conversationRootUsesGroupedHomeFeed = false; private boolean rootTabRefreshInFlight = false; private boolean pendingRootTabRefresh = false; private boolean realtimeRefreshScheduled = false; + private boolean notificationPermissionRequestScheduled = false; private final java.util.HashMap recentRealtimeEventTimestamps = new java.util.HashMap<>(); private final Set selectedConversationProjectIds = new LinkedHashSet<>(); private @Nullable RootPagerAdapter rootPagerAdapter; @@ -142,8 +164,21 @@ public class MainActivity extends AppCompatActivity { protected void onCreate(@Nullable Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_main); - apiClient = new BossApiClient(this); - realtimeClient = new BossRealtimeClient(apiClient, new BossRealtimeClient.Listener() { + apiClient = createApiClient(); + realtimeClient = createRealtimeClient(apiClient); + bindViews(); + bindActions(); + configureBackNavigation(); + applyInitialTab(getIntent()); + bootstrapSession(); + } + + BossApiClient createApiClient() { + return new BossApiClient(this); + } + + BossRealtimeClient createRealtimeClient(BossApiClient client) { + return new BossRealtimeClient(client, new BossRealtimeClient.Listener() { @Override public void onRealtimeEvent(BossRealtimeEvent event) { handleRealtimeEvent(event); @@ -154,10 +189,6 @@ public class MainActivity extends AppCompatActivity { runOnUiThread(() -> handleRealtimeConnectionChanged(connected)); } }); - bindViews(); - bindActions(); - applyInitialTab(getIntent()); - bootstrapSession(); } @Override @@ -171,32 +202,44 @@ public class MainActivity extends AppCompatActivity { } } - @Override - public void onBackPressed() { + private void configureBackNavigation() { + getOnBackPressedDispatcher().addCallback(this, new OnBackPressedCallback(true) { + @Override + public void handleOnBackPressed() { + if (handleRootBackPressed()) { + return; + } + setEnabled(false); + getOnBackPressedDispatcher().onBackPressed(); + } + }); + } + + private boolean handleRootBackPressed() { if (contentPanel.getVisibility() == View.VISIBLE && conversationSearchMode) { exitConversationSearchMode(true); - return; + return true; } if (contentPanel.getVisibility() == View.VISIBLE && conversationQuickActionsVisible) { hideConversationQuickActions(true); - return; + return true; } if (contentPanel.getVisibility() == View.VISIBLE && !"conversations".equals(activeTab)) { setActiveTab("conversations", false); persistLastRootTab("conversations"); - return; + return true; } if (contentPanel.getVisibility() == View.VISIBLE) { long now = System.currentTimeMillis(); if (now - lastRootBackPressedAt < ROOT_BACK_EXIT_WINDOW_MS) { moveTaskToBack(true); - return; + return true; } lastRootBackPressedAt = now; showMessage("再按一次返回,应用进入后台"); - return; + return true; } - super.onBackPressed(); + return false; } @Override @@ -204,6 +247,7 @@ public class MainActivity extends AppCompatActivity { cancelConversationAutoRefresh(); cancelRealtimeRefreshSchedule(); stopRealtimeUpdates(); + sessionExecutor.shutdownNow(); executor.shutdownNow(); super.onDestroy(); } @@ -214,6 +258,17 @@ public class MainActivity extends AppCompatActivity { conversationAutoRefreshEnabled = true; updateConversationAutoRefresh(); updateRealtimeSubscription(); + maybeRequestNotificationPermission(); + if ( + contentPanel != null && + contentPanel.getVisibility() == View.VISIBLE && + "conversations".equals(activeTab) && + apiClient != null && + apiClient.hasSessionHints() && + !rootTabRefreshInFlight + ) { + refreshConversationsData(); + } } @Override @@ -232,7 +287,16 @@ public class MainActivity extends AppCompatActivity { mainTopBar = findViewById(R.id.main_top_bar); loginTitle = findViewById(R.id.login_title); loginHint = findViewById(R.id.login_hint); + loginAccountInput = findViewById(R.id.login_account_input); + loginPasswordInput = findViewById(R.id.login_password_input); + loginConfirmPasswordInput = findViewById(R.id.login_confirm_password_input); + loginCodeInput = findViewById(R.id.login_code_input); + loginCodeRow = findViewById(R.id.login_code_row); + loginSendCodeButton = findViewById(R.id.login_send_code_button); loginButton = findViewById(R.id.login_button); + loginModeButton = findViewById(R.id.login_mode_button); + registerModeButton = findViewById(R.id.register_mode_button); + forgotModeButton = findViewById(R.id.forgot_mode_button); loginProgress = findViewById(R.id.login_progress); backButton = findViewById(R.id.back_button); topTitle = findViewById(R.id.top_title); @@ -259,12 +323,17 @@ public class MainActivity extends AppCompatActivity { loginTitle.setText(WechatSurfaceMapper.loginTitle()); loginHint.setText(WechatSurfaceMapper.loginHintText()); loginButton.setText(WechatSurfaceMapper.loginButtonLabel()); + setAuthMode("login", WechatSurfaceMapper.loginHintText()); BossWindowInsets.applyStatusBarInset(loginShell); BossWindowInsets.applyStatusBarInset(mainTopBar); } private void bindActions() { - loginButton.setOnClickListener(v -> performAutoLogin()); + loginButton.setOnClickListener(v -> performPrimaryAuthAction()); + loginSendCodeButton.setOnClickListener(v -> sendAuthVerificationCode()); + loginModeButton.setOnClickListener(v -> setAuthMode("login", "请输入账号和密码登录。")); + registerModeButton.setOnClickListener(v -> setAuthMode("register", "注册后会自动登录并进入会话。")); + forgotModeButton.setOnClickListener(v -> setAuthMode("forgot", "通过验证码重置密码后再登录。")); backButton.setVisibility(View.GONE); backButton.setOnClickListener(v -> { if (conversationSearchMode) { @@ -348,7 +417,7 @@ public class MainActivity extends AppCompatActivity { } setLoginLoading(true, "正在恢复上次登录状态..."); - executor.execute(() -> { + sessionExecutor.execute(() -> { try { BossApiClient.ApiResponse sessionResponse = apiClient.getSession(); if (!sessionResponse.ok()) { @@ -365,15 +434,52 @@ public class MainActivity extends AppCompatActivity { } catch (Exception ignored) { // Fall back to login panel. } - runOnUiThread(() -> setLoginLoading(false, WechatSurfaceMapper.loginHintText())); + runOnUiThread(() -> setLoginLoading(false, "登录已过期,请重新输入账号密码。")); }); } - private void performAutoLogin() { - setLoginLoading(true, "正在创建会话..."); - executor.execute(() -> { + private void performPrimaryAuthAction() { + String account = inputText(loginAccountInput); + String password = inputText(loginPasswordInput); + String confirmPassword = inputText(loginConfirmPasswordInput); + String code = inputText(loginCodeInput); + if (account.isEmpty()) { + setLoginLoading(false, "请先填写账号。"); + return; + } + if (password.isEmpty()) { + setLoginLoading(false, "请先填写密码。"); + return; + } + if (!"login".equals(authMode) && confirmPassword.isEmpty()) { + setLoginLoading(false, "请再次确认密码。"); + return; + } + if (!"login".equals(authMode) && !password.equals(confirmPassword)) { + setLoginLoading(false, "两次输入的密码不一致。"); + return; + } + if (!"login".equals(authMode) && code.isEmpty()) { + setLoginLoading(false, "请先填写验证码。"); + return; + } + + if ("register".equals(authMode)) { + performRegisterAndLogin(account, password, confirmPassword, code); + return; + } + if ("forgot".equals(authMode)) { + performPasswordReset(account, password, confirmPassword, code); + return; + } + performPasswordLogin(account, password); + } + + private void performPasswordLogin(String account, String password) { + setLoginLoading(true, "正在登录..."); + sessionExecutor.execute(() -> { try { - BossApiClient.ApiResponse response = apiClient.autoLogin(); + BossApiClient.ApiResponse response = apiClient.loginWithPassword(account, password); if (response.ok()) { JSONObject session = response.json.optJSONObject("session"); runOnUiThread(() -> { @@ -389,6 +495,78 @@ public class MainActivity extends AppCompatActivity { }); } + private void performRegisterAndLogin(String account, String password, String confirmPassword, String code) { + setLoginLoading(true, "正在注册..."); + sessionExecutor.execute(() -> { + try { + BossApiClient.ApiResponse registerResponse = apiClient.registerAccount( + account, + password, + confirmPassword, + code + ); + if (!registerResponse.ok()) { + runOnUiThread(() -> setLoginLoading(false, "注册失败:" + registerResponse.message())); + return; + } + BossApiClient.ApiResponse loginResponse = apiClient.loginWithPassword(account, password); + if (loginResponse.ok()) { + JSONObject session = loginResponse.json.optJSONObject("session"); + runOnUiThread(() -> { + showContent(); + refreshAllData(session); + }); + return; + } + runOnUiThread(() -> { + setAuthMode("login", "注册成功,请用刚才的账号密码登录。"); + setLoginLoading(false, "注册成功,请用刚才的账号密码登录。"); + }); + } catch (Exception error) { + runOnUiThread(() -> setLoginLoading(false, "注册链路异常:" + error.getMessage())); + } + }); + } + + private void performPasswordReset(String account, String password, String confirmPassword, String code) { + setLoginLoading(true, "正在重置密码..."); + sessionExecutor.execute(() -> { + try { + BossApiClient.ApiResponse response = apiClient.resetPassword(account, password, confirmPassword, code); + if (response.ok()) { + runOnUiThread(() -> { + clearSecretInputs(); + setAuthMode("login", "密码已重置,请使用新密码登录。"); + }); + return; + } + runOnUiThread(() -> setLoginLoading(false, "重置失败:" + response.message())); + } catch (Exception error) { + runOnUiThread(() -> setLoginLoading(false, "重置链路异常:" + error.getMessage())); + } + }); + } + + private void sendAuthVerificationCode() { + String account = inputText(loginAccountInput); + if (account.isEmpty()) { + setLoginLoading(false, "请先填写账号。"); + return; + } + String purpose = "forgot".equals(authMode) ? "forgot-password" : "register"; + setLoginLoading(true, "正在发送验证码..."); + sessionExecutor.execute(() -> { + try { + BossApiClient.ApiResponse response = apiClient.sendVerificationCode(account, purpose); + runOnUiThread(() -> setLoginLoading(false, response.ok() + ? "验证码已发送,请查看对应邮箱或短信。" + : "验证码发送失败:" + response.message())); + } catch (Exception error) { + runOnUiThread(() -> setLoginLoading(false, "验证码链路异常:" + error.getMessage())); + } + }); + } + void refreshCurrentTab() { if (rootTabRefreshInFlight) { pendingRootTabRefresh = true; @@ -413,9 +591,11 @@ public class MainActivity extends AppCompatActivity { JSONObject session = ensureActiveSession(); BossApiClient.ApiResponse conversations = null; boolean conversationsOk = false; + boolean usedGroupedHomeFeed = false; try { conversations = apiClient.getConversationHome(); conversationsOk = conversations.ok(); + usedGroupedHomeFeed = conversationsOk; } catch (Exception ignored) { conversationsOk = false; } @@ -425,6 +605,7 @@ public class MainActivity extends AppCompatActivity { if (fallbackConversations.ok()) { conversations = fallbackConversations; conversationsOk = true; + usedGroupedHomeFeed = false; } } catch (Exception ignored) { conversationsOk = false; @@ -433,18 +614,24 @@ public class MainActivity extends AppCompatActivity { BossApiClient.ApiResponse finalConversations = conversations; final boolean finalConversationsOk = conversationsOk; + final boolean finalUsedGroupedHomeFeed = usedGroupedHomeFeed; runOnUiThread(() -> { sessionData = session; JSONArray refreshedConversations = finalConversations == null ? null - : WechatSurfaceMapper.normalizeConversationHomeFeed( - finalConversations.json.optJSONArray("conversations") - ); + : finalUsedGroupedHomeFeed + ? finalConversations.json.optJSONArray("conversations") + : WechatSurfaceMapper.normalizeConversationHomeFeed( + finalConversations.json.optJSONArray("conversations") + ); conversationsData = WechatSurfaceMapper.resolveRefreshValue( conversationsData, refreshedConversations, finalConversationsOk ); + if (finalConversationsOk) { + conversationRootUsesGroupedHomeFeed = finalUsedGroupedHomeFeed; + } maybeApplyPreferredEntry(); renderCurrentTab(); startRefreshing(false); @@ -612,7 +799,11 @@ public class MainActivity extends AppCompatActivity { return false; } JSONObject conversationItem = event.payload.optJSONObject("conversationItem"); - if (conversationItem == null) { + JSONObject threadConversationItem = event.payload.optJSONObject("threadConversationItem"); + JSONObject patchItem = conversationRootUsesGroupedHomeFeed + ? (conversationItem != null ? conversationItem : threadConversationItem) + : (threadConversationItem != null ? threadConversationItem : conversationItem); + if (patchItem == null) { return false; } runOnUiThread(() -> { @@ -622,7 +813,7 @@ public class MainActivity extends AppCompatActivity { } conversationsData = WechatSurfaceMapper.mergeConversationHomeItem( conversationsData, - conversationItem, + patchItem, affectedProjectId ); renderCurrentTab(); @@ -631,8 +822,11 @@ public class MainActivity extends AppCompatActivity { } private void scheduleRealtimeRefresh() { - realtimeRefreshScheduled = false; - refreshCurrentTab(); + if (realtimeRefreshScheduled) { + return; + } + realtimeRefreshScheduled = true; + uiHandler.postDelayed(realtimeRefreshRunnable, REALTIME_REFRESH_DEBOUNCE_MS); } private void cancelRealtimeRefreshSchedule() { @@ -729,6 +923,7 @@ public class MainActivity extends AppCompatActivity { BossApiClient.ApiResponse ota = null; BossApiClient.ApiResponse settings = null; boolean conversationsOk = false; + boolean usedGroupedHomeFeed = false; boolean devicesOk = false; boolean otaOk = false; boolean settingsOk = false; @@ -736,6 +931,7 @@ public class MainActivity extends AppCompatActivity { try { conversations = apiClient.getConversationHome(); conversationsOk = conversations.ok(); + usedGroupedHomeFeed = conversationsOk; } catch (Exception ignored) { conversationsOk = false; } @@ -745,6 +941,7 @@ public class MainActivity extends AppCompatActivity { if (fallbackConversations.ok()) { conversations = fallbackConversations; conversationsOk = true; + usedGroupedHomeFeed = false; } } catch (Exception ignored) { conversationsOk = false; @@ -775,6 +972,7 @@ public class MainActivity extends AppCompatActivity { BossApiClient.ApiResponse finalOta = ota; BossApiClient.ApiResponse finalSettings = settings; final boolean finalConversationsOk = conversationsOk; + final boolean finalUsedGroupedHomeFeed = usedGroupedHomeFeed; final boolean finalDevicesOk = devicesOk; final boolean finalOtaOk = otaOk; final boolean finalSettingsOk = settingsOk; @@ -782,14 +980,19 @@ public class MainActivity extends AppCompatActivity { sessionData = finalSession; JSONArray refreshedConversations = finalConversations == null ? null - : WechatSurfaceMapper.normalizeConversationHomeFeed( - finalConversations.json.optJSONArray("conversations") - ); + : finalUsedGroupedHomeFeed + ? finalConversations.json.optJSONArray("conversations") + : WechatSurfaceMapper.normalizeConversationHomeFeed( + finalConversations.json.optJSONArray("conversations") + ); conversationsData = WechatSurfaceMapper.resolveRefreshValue( conversationsData, refreshedConversations, finalConversationsOk ); + if (finalConversationsOk) { + conversationRootUsesGroupedHomeFeed = finalUsedGroupedHomeFeed; + } devicesData = WechatSurfaceMapper.resolveRefreshValue( devicesData, finalDevices == null ? null : finalDevices.json.optJSONArray("devices"), @@ -865,6 +1068,7 @@ public class MainActivity extends AppCompatActivity { private void showLogin(String hint) { loginPanel.setVisibility(View.VISIBLE); contentPanel.setVisibility(View.GONE); + setAuthMode("login", hint); setLoginLoading(false, hint); stopRealtimeUpdates(); } @@ -875,15 +1079,76 @@ public class MainActivity extends AppCompatActivity { setActiveTab(activeTab, false); updateConversationAutoRefresh(); updateRealtimeSubscription(); + scheduleNotificationPermissionRequest(); } private void setLoginLoading(boolean loading, String hint) { loginProgress.setVisibility(loading ? View.VISIBLE : View.GONE); loginButton.setEnabled(!loading); - loginButton.setText(loading ? "处理中..." : WechatSurfaceMapper.loginButtonLabel()); + loginSendCodeButton.setEnabled(!loading); + loginModeButton.setEnabled(!loading); + registerModeButton.setEnabled(!loading); + forgotModeButton.setEnabled(!loading); + loginButton.setText(loading ? "处理中..." : primaryAuthButtonLabel()); loginHint.setText(hint); } + private void setAuthMode(String mode, String hint) { + authMode = ("register".equals(mode) || "forgot".equals(mode)) ? mode : "login"; + boolean codeMode = !"login".equals(authMode); + loginTitle.setText(authTitle()); + loginButton.setText(primaryAuthButtonLabel()); + loginPasswordInput.setHint("forgot".equals(authMode) ? "新密码" : "密码"); + loginConfirmPasswordInput.setVisibility(codeMode ? View.VISIBLE : View.GONE); + loginCodeRow.setVisibility(codeMode ? View.VISIBLE : View.GONE); + loginHint.setText(hint); + tintAuthModeButtons(); + } + + private String authTitle() { + if ("register".equals(authMode)) { + return "注册账号"; + } + if ("forgot".equals(authMode)) { + return "找回密码"; + } + return "登录 Boss"; + } + + private String primaryAuthButtonLabel() { + if ("register".equals(authMode)) { + return "注册并登录"; + } + if ("forgot".equals(authMode)) { + return "重置密码"; + } + return "登录"; + } + + private void tintAuthModeButtons() { + int selectedColor = getColor(R.color.boss_green); + int mutedColor = getColor(R.color.boss_text_muted); + loginModeButton.setTextColor("login".equals(authMode) ? selectedColor : mutedColor); + registerModeButton.setTextColor("register".equals(authMode) ? selectedColor : mutedColor); + forgotModeButton.setTextColor("forgot".equals(authMode) ? selectedColor : mutedColor); + } + + private String inputText(EditText input) { + return input == null || input.getText() == null ? "" : input.getText().toString().trim(); + } + + private void clearSecretInputs() { + if (loginPasswordInput != null) { + loginPasswordInput.setText(""); + } + if (loginConfirmPasswordInput != null) { + loginConfirmPasswordInput.setText(""); + } + if (loginCodeInput != null) { + loginCodeInput.setText(""); + } + } + private void setActiveTab(String tab, boolean fromUser) { if (!"conversations".equals(tab)) { exitConversationSelectionMode(); @@ -942,14 +1207,22 @@ public class MainActivity extends AppCompatActivity { } private void updateTabStyles() { - styleTab(tabConversations, "conversations".equals(activeTab)); - styleTab(tabDevices, "devices".equals(activeTab)); - styleTab(tabMe, "me".equals(activeTab)); + styleTab(tabConversations, "conversations".equals(activeTab), R.drawable.ic_boss_tab_chat); + styleTab(tabDevices, "devices".equals(activeTab), R.drawable.ic_boss_tab_devices); + styleTab(tabMe, "me".equals(activeTab), R.drawable.ic_boss_tab_me); } - private void styleTab(Button button, boolean active) { - button.setBackgroundResource(active ? R.drawable.bg_tab_active : R.drawable.bg_tab_inactive); - button.setTextColor(getColor(active ? R.color.boss_green : R.color.boss_text_muted)); + private void styleTab(Button button, boolean active, int iconRes) { + int color = getColor(active ? R.color.boss_green : R.color.boss_text_muted); + button.setBackgroundColor(Color.TRANSPARENT); + button.setTextColor(color); + button.setTextSize(12); + button.setAllCaps(false); + button.setGravity(android.view.Gravity.CENTER); + button.setCompoundDrawablesWithIntrinsicBounds(0, iconRes, 0, 0); + button.setCompoundDrawablePadding(BossUi.dp(this, 3)); + button.setCompoundDrawableTintList(ColorStateList.valueOf(color)); + button.setPadding(0, BossUi.dp(this, 5), 0, BossUi.dp(this, 3)); } private void configureTopAction(WechatSurfaceMapper.RootTopAction action) { @@ -970,7 +1243,7 @@ public class MainActivity extends AppCompatActivity { topSearchInput.setVisibility(View.GONE); backButton.setVisibility(View.GONE); searchButton.setVisibility(View.GONE); - WechatSurfaceMapper.RootTopAction action = WechatSurfaceMapper.rootTopAction(activeTab, false); + WechatSurfaceMapper.RootTopAction action = WechatSurfaceMapper.rootTopAction(activeTab, false, false, currentSessionRole()); refreshButton.setVisibility(View.VISIBLE); configureTopAction(action); } @@ -1005,7 +1278,7 @@ public class MainActivity extends AppCompatActivity { refreshButton.setEnabled(true); return; } - WechatSurfaceMapper.RootTopAction action = WechatSurfaceMapper.rootTopAction(activeTab, refreshing, conversationSelectionMode); + WechatSurfaceMapper.RootTopAction action = WechatSurfaceMapper.rootTopAction(activeTab, refreshing, conversationSelectionMode, currentSessionRole()); configureTopAction(action); refreshButton.setEnabled(!"refresh".equals(action.actionKey) || !refreshing); refreshButton.setAlpha(refreshing && "refresh".equals(action.actionKey) ? 0.45f : 1f); @@ -1036,7 +1309,7 @@ public class MainActivity extends AppCompatActivity { toggleConversationQuickActions(); return; } - String actionKey = WechatSurfaceMapper.rootTopAction(activeTab, false, conversationSelectionMode).actionKey; + String actionKey = WechatSurfaceMapper.rootTopAction(activeTab, false, conversationSelectionMode, currentSessionRole()).actionKey; if ("add_device".equals(actionKey)) { startActivity(new Intent(this, DeviceEnrollmentActivity.class)); return; @@ -1181,11 +1454,19 @@ public class MainActivity extends AppCompatActivity { String matchedProjectId = item.optString("searchMatchProjectId", "").trim(); String matchedProjectLabel = item.optString("searchMatchLabel", "").trim(); if (!matchedProjectId.isEmpty() && !matchedProjectLabel.isEmpty()) { - exitConversationSearchMode(true); openProject(matchedProjectId, matchedProjectLabel); + exitConversationSearchMode(true); return; } + openConversationFolder( + folderKey, + resolveConversationFolderName(item, row), + item.optString("searchMatchProjectId", ""), + item.optJSONArray("searchMatchProjectIds"), + item.optString("searchMatchLabel", "") + ); exitConversationSearchMode(true); + return; } openConversationFolder( folderKey, @@ -1543,6 +1824,7 @@ public class MainActivity extends AppCompatActivity { } private void prepareConversationQuickActionMenu() { + quickActionAddDevice.setVisibility("highest_admin".equals(currentSessionRole()) ? View.VISIBLE : View.GONE); conversationQuickActionsMenu.setVisibility(View.VISIBLE); conversationQuickActionsMenu.setAlpha(0f); conversationQuickActionsMenu.setTranslationY(-BossUi.dp(this, 6)); @@ -1553,6 +1835,7 @@ public class MainActivity extends AppCompatActivity { conversationQuickActionsMenu.setAlpha(0f); conversationQuickActionsMenu.setTranslationY(-BossUi.dp(this, 6)); conversationQuickActionsMenu.setVisibility(View.GONE); + quickActionAddDevice.setVisibility(View.VISIBLE); } static boolean matchesConversationQuery(JSONObject item, String rawQuery) { @@ -1706,7 +1989,7 @@ public class MainActivity extends AppCompatActivity { (roleLabel.isEmpty() ? "主控账号已启用安全保护" : roleLabel + " · 主控账号已启用安全保护") )); - for (WechatSurfaceMapper.MeMenuItem item : WechatSurfaceMapper.rootMeMenuItems()) { + for (WechatSurfaceMapper.MeMenuItem item : WechatSurfaceMapper.rootMeMenuItemsForRole(currentSessionRole())) { screenContent.addView(BossUi.buildWechatMenuRow( this, item.title, @@ -1856,6 +2139,33 @@ public class MainActivity extends AppCompatActivity { } } + private void maybeRequestNotificationPermission() { + notificationPermissionRequestScheduled = false; + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) { + return; + } + if (contentPanel == null || contentPanel.getVisibility() != View.VISIBLE) { + return; + } + if (checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) == PackageManager.PERMISSION_GRANTED) { + return; + } + android.content.SharedPreferences prefs = getSharedPreferences(UI_PREFS, Context.MODE_PRIVATE); + if (prefs.getBoolean(KEY_NOTIFICATION_PERMISSION_REQUESTED, false)) { + return; + } + prefs.edit().putBoolean(KEY_NOTIFICATION_PERMISSION_REQUESTED, true).apply(); + requestPermissions(new String[]{Manifest.permission.POST_NOTIFICATIONS}, REQUEST_POST_NOTIFICATIONS); + } + + private void scheduleNotificationPermissionRequest() { + if (notificationPermissionRequestScheduled) { + return; + } + notificationPermissionRequestScheduled = true; + uiHandler.postDelayed(this::maybeRequestNotificationPermission, 450L); + } + void handleRealtimeConnectionChanged(boolean connected) { if (!connected && shouldMaintainConversationAutoRefresh() @@ -1868,14 +2178,27 @@ public class MainActivity extends AppCompatActivity { } private void openMeEntry(String key) { + if (!WechatSurfaceMapper.canOpenMeEntryForRole(key, currentSessionRole())) { + showMessage("当前账号没有权限打开这个入口。"); + return; + } Intent intent; switch (key) { case "security": intent = new Intent(this, SecurityActivity.class); break; + case "access": + intent = new Intent(this, AccessManagementActivity.class); + break; case "ai_accounts": intent = new Intent(this, AiAccountsActivity.class); break; + case "storage": + intent = new Intent(this, StorageSettingsActivity.class); + break; + case "telegram": + intent = new Intent(this, TelegramIntegrationActivity.class); + break; case "settings": intent = new Intent(this, SettingsActivity.class); break; @@ -1895,6 +2218,13 @@ public class MainActivity extends AppCompatActivity { startActivity(intent); } + private String currentSessionRole() { + if (sessionData == null) { + return "member"; + } + return sessionData.optString("role", "member"); + } + private void openSkillInventoryFromMe() { String targetDeviceId = resolveSkillTargetDeviceId(); if (targetDeviceId == null || targetDeviceId.isEmpty()) { diff --git a/android/app/src/main/java/com/hyzq/boss/ProjectChatUiState.java b/android/app/src/main/java/com/hyzq/boss/ProjectChatUiState.java index 73e8dea..d1ca9ec 100644 --- a/android/app/src/main/java/com/hyzq/boss/ProjectChatUiState.java +++ b/android/app/src/main/java/com/hyzq/boss/ProjectChatUiState.java @@ -14,6 +14,30 @@ import java.util.Set; public final class ProjectChatUiState { private ProjectChatUiState() {} + public static final class MessageDisplayItem { + public static final String TYPE_MESSAGE = "message"; + public static final String TYPE_PROCESS_GROUP = "process_group"; + + public final String type; + @Nullable + public final JSONObject message; + public final List processMessages; + + private MessageDisplayItem(String type, @Nullable JSONObject message, List 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 processMessages) { + return new MessageDisplayItem(TYPE_PROCESS_GROUP, null, processMessages); + } + } + public static final class SelectionState { public final boolean multiSelecting; public final Set selectedMessageIds; @@ -31,6 +55,7 @@ public final class ProjectChatUiState { public final boolean showMultiSelectBar; public final boolean showRefresh; public final boolean showHeaderAction; + public final boolean copyEnabled; public final boolean forwardEnabled; public final String backLabel; public final String title; @@ -42,6 +67,7 @@ public final class ProjectChatUiState { boolean showMultiSelectBar, boolean showRefresh, boolean showHeaderAction, + boolean copyEnabled, boolean forwardEnabled, String backLabel, String title, @@ -52,6 +78,7 @@ public final class ProjectChatUiState { this.showMultiSelectBar = showMultiSelectBar; this.showRefresh = showRefresh; this.showHeaderAction = showHeaderAction; + this.copyEnabled = copyEnabled; this.forwardEnabled = forwardEnabled; this.backLabel = backLabel; this.title = title; @@ -81,6 +108,77 @@ public final class ProjectChatUiState { return nearBottom || forced; } + public static List buildMessageDisplayItems(@Nullable JSONArray messages) { + ArrayList items = new ArrayList<>(); + if (messages == null || messages.length() == 0) { + return items; + } + ArrayList 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 items, List pendingProcessMessages) { + if (pendingProcessMessages.isEmpty()) { + return; + } + items.add(MessageDisplayItem.processGroup(pendingProcessMessages)); + pendingProcessMessages.clear(); + } + public static String threadExecutionConflictTitle(@Nullable JSONObject conflict) { if (conflict == null) { return "当前线程命中冲突保护"; @@ -149,6 +247,10 @@ public final class ProjectChatUiState { return state != null && state.multiSelecting && state.selectedMessageIds.size() >= 2; } + public static boolean canCopySelection(@Nullable SelectionState state) { + return state != null && state.multiSelecting && !state.selectedMessageIds.isEmpty(); + } + public static SelectionState reconcileSelection( @Nullable SelectionState current, @Nullable List availableMessageIds @@ -181,6 +283,7 @@ public final class ProjectChatUiState { true, false, false, + canCopySelection(selectionState), canForwardSelection(selectionState), "取消", "已选 " + selectedCount + " 条", @@ -194,6 +297,7 @@ public final class ProjectChatUiState { !conversationInfoReady, conversationInfoReady, false, + false, "返回", isBlank(defaultTitle) ? "项目详情" : defaultTitle, isBlank(defaultSubtitle) ? "原生页面" : defaultSubtitle @@ -420,6 +524,13 @@ public final class ProjectChatUiState { if ("completed".equals(taskStatus) || "failed".equals(taskStatus)) { return new ReplyWaitSpec(false, null); } + JSONObject replyMessage = response.optJSONObject("replyMessage"); + if (replyMessage != null) { + String replyMessageId = replyMessage.optString("id", "").trim(); + if (!replyMessageId.isEmpty()) { + return new ReplyWaitSpec(true, replyMessageId); + } + } JSONObject message = response.optJSONObject("message"); return new ReplyWaitSpec(true, message == null ? null : message.optString("id", "")); } @@ -444,6 +555,14 @@ public final class ProjectChatUiState { return !isBlank(latestMessageId) && !baselineMessageId.trim().equals(latestMessageId); } + public static boolean shouldAutoRefreshConversation( + boolean shouldMaintainAutoRefresh, + boolean realtimeConnected, + boolean trackedMasterReplyTimedOut + ) { + return shouldMaintainAutoRefresh && (!realtimeConnected || trackedMasterReplyTimedOut); + } + @Nullable public static String latestMessageId(@Nullable JSONArray messages) { if (messages == null || messages.length() == 0) { @@ -457,10 +576,110 @@ public final class ProjectChatUiState { return messageId.isEmpty() ? null : messageId; } + private static boolean isThreadProcessMessage(@Nullable JSONObject message) { + if (message == null) { + return false; + } + String kind = message.optString("kind", "").trim(); + if ("thread_process".equals(kind)) { + return true; + } + if (!isBlank(kind) + && !"text".equals(kind) + && !"conversation_reply".equals(kind) + && !"thread_reply".equals(kind)) { + return false; + } + String sender = message.optString("sender", "").trim().toLowerCase(java.util.Locale.ROOT); + String senderLabel = message.optString("senderLabel", "").trim(); + if ("user".equals(sender) + || "master".equals(sender) + || "ops".equals(sender) + || "audit".equals(sender) + || senderLabel.contains("主 Agent") + || senderLabel.contains("审计") + || senderLabel.contains("你")) { + return false; + } + String body = compactBody(message.optString("body", "")); + if (body.isEmpty()) { + return false; + } + if (isStructuredNumberedProcessBody(body)) { + return true; + } + if (containsAny(body, FOLD_BLOCK_MARKERS)) { + return false; + } + return hasProcessProgressMarker(body); + } + private static boolean isBlank(@Nullable String value) { return value == null || value.trim().isEmpty(); } + private static String compactBody(@Nullable String value) { + if (value == null) { + return ""; + } + return value + .replace("\r\n", "\n") + .replace('\r', '\n') + .replaceAll("\\n{2,}", "\n") + .trim(); + } + + private static boolean containsAny(String body, String[] markers) { + String normalizedBody = body.toLowerCase(java.util.Locale.ROOT); + for (String marker : markers) { + if (normalizedBody.contains(marker.toLowerCase(java.util.Locale.ROOT))) { + return true; + } + } + return false; + } + + private static boolean isStructuredNumberedProcessBody(String body) { + String[] rawLines = body + .replace("\r\n", "\n") + .replace('\r', '\n') + .split("\n"); + ArrayList numberedLines = new ArrayList<>(); + for (String rawLine : rawLines) { + String normalizedLine = compactBody(rawLine); + if (normalizedLine.isEmpty()) { + continue; + } + if (normalizedLine.matches("^\\d+[.)、]\\s*.+$")) { + numberedLines.add(normalizedLine); + } + } + if (numberedLines.size() < 2) { + return false; + } + String merged = android.text.TextUtils.join(" ", numberedLines) + .toLowerCase(java.util.Locale.ROOT); + return containsAny(merged, PROCESS_PROGRESS_NUMBERED_HINTS); + } + + private static boolean hasProcessProgressMarker(String body) { + String normalizedBody = body.trim().toLowerCase(java.util.Locale.ROOT); + if (isStructuredNumberedProcessBody(body)) { + return true; + } + for (String marker : PROCESS_PROGRESS_PREFIXES) { + if (normalizedBody.startsWith(marker.toLowerCase(java.util.Locale.ROOT))) { + return true; + } + } + for (String marker : PROCESS_PROGRESS_CONTAINS) { + if (normalizedBody.contains(marker.toLowerCase(java.util.Locale.ROOT))) { + return true; + } + } + return false; + } + private static String truncate(@Nullable String value, int maxLength) { String normalized = value == null ? "" : value.trim(); if (normalized.length() <= maxLength) { @@ -468,4 +687,83 @@ public final class ProjectChatUiState { } return normalized.substring(0, maxLength) + "…"; } + + private static final String[] PROCESS_PROGRESS_PREFIXES = new String[] { + "我先", + "我现在", + "我会先", + "我发现", + "我准备", + "接下来", + "正在", + "先看", + "先读", + "我把", + "我再", + "目前在", + "现在在", + "补一组", + "处理一下", + "先确认", + "准备", + "同步一下", + "我这边已经" + }; + + private static final String[] PROCESS_PROGRESS_CONTAINS = new String[] { + "我继续", + "我已经在", + "正在跑", + "正在检查", + "正在处理", + "正在同步", + "我会直接", + "我先把", + "先补", + "再接" + }; + + private static final String[] PROCESS_PROGRESS_NUMBERED_HINTS = new String[] { + "先", + "再", + "接下来", + "然后", + "检查", + "确认", + "处理", + "同步", + "补", + "排查", + "推进", + "回你", + "回传", + "会把", + "我会" + }; + + private static final String[] FOLD_BLOCK_MARKERS = new String[] { + "失败", + "报错", + "错误", + "阻塞", + "不能", + "无法", + "崩溃", + "超时", + "exception", + "error", + "fatal", + "结论", + "最终", + "总结", + "已完成", + "已经完成", + "验证通过", + "测试通过", + "已修复", + "修好了", + "已部署", + "已安装", + "可以直接" + }; } diff --git a/android/app/src/main/java/com/hyzq/boss/ProjectDetailActivity.java b/android/app/src/main/java/com/hyzq/boss/ProjectDetailActivity.java index c0f2800..824f279 100644 --- a/android/app/src/main/java/com/hyzq/boss/ProjectDetailActivity.java +++ b/android/app/src/main/java/com/hyzq/boss/ProjectDetailActivity.java @@ -6,7 +6,11 @@ import android.content.ClipboardManager; import android.content.ActivityNotFoundException; import android.content.Intent; import android.database.Cursor; +import android.graphics.Color; +import android.graphics.Typeface; +import android.graphics.drawable.GradientDrawable; import android.net.Uri; +import android.os.Build; import android.os.Bundle; import android.os.Handler; import android.os.Looper; @@ -24,6 +28,7 @@ import android.widget.LinearLayout; import android.widget.ScrollView; import android.widget.TextView; +import androidx.activity.OnBackPressedCallback; import androidx.activity.result.ActivityResultLauncher; import androidx.activity.result.contract.ActivityResultContracts; import androidx.annotation.Nullable; @@ -36,8 +41,14 @@ import org.json.JSONObject; import java.io.File; import java.io.FileOutputStream; import java.io.InputStream; +import java.text.ParseException; +import java.text.SimpleDateFormat; import java.util.ArrayList; +import java.util.Date; +import java.util.HashMap; import java.util.List; +import java.util.Locale; +import java.util.Map; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.RejectedExecutionException; @@ -48,7 +59,7 @@ public class ProjectDetailActivity extends BossScreenActivity { private static final long CONVERSATION_AUTO_REFRESH_MS = 8_000L; private static final long REALTIME_REFRESH_DEBOUNCE_MS = 300L; private static final long REPLY_WAIT_TIMEOUT_MS = 55_000L; - private static final long REPLY_WAIT_POLL_INTERVAL_MS = 1_500L; + private static final long REPLY_WAIT_POLL_INTERVAL_MS = 800L; private static final long REALTIME_RELOAD_THROTTLE_MS = 900L; private String projectId; @@ -62,11 +73,15 @@ public class ProjectDetailActivity extends BossScreenActivity { private LinearLayout quickActionsLayout; private LinearLayout composerRow; private LinearLayout multiSelectActionsLayout; + private LinearLayout mentionSuggestionsPanel; private ImageButton composerAttachmentButton; private EditText composerInput; private Button composerSendButton; + private Button multiSelectCopyButton; private Button multiSelectForwardButton; private ScrollView chatScrollView; + private ImageButton scrollBottomButton; + private int lastObservedChatScrollY = Integer.MIN_VALUE; private View pendingOutgoingBubble; private boolean composerSending; private @Nullable String pendingReplyPresenter; @@ -76,6 +91,7 @@ public class ProjectDetailActivity extends BossScreenActivity { private boolean masterAgentReplyWaiting; private boolean masterAgentReplyTimedOut; private @Nullable String masterAgentReplyBaselineMessageId; + private boolean suppressMentionSuggestionUpdate; private String currentScreenTitle; private String currentBaseSubtitle; private String currentScreenSubtitle; @@ -86,6 +102,10 @@ public class ProjectDetailActivity extends BossScreenActivity { private @Nullable JSONObject currentRejectedDispatchPlan; private @Nullable JSONObject currentParticipantsPayload; private @Nullable JSONObject currentRenderedProjectPayload; + private final HashMap currentDeviceAvatarById = new HashMap<>(); + private final HashMap currentDeviceNameById = new HashMap<>(); + private String currentProjectDeviceAvatarLabel = ""; + private String currentProjectDeviceName = ""; private ProjectChatUiState.SelectionState selectionState = ProjectChatUiState.emptySelection(); private ActivityResultLauncher conversationInfoLauncher; private ActivityResultLauncher masterAgentPromptLauncher; @@ -109,6 +129,10 @@ public class ProjectDetailActivity extends BossScreenActivity { private boolean pendingReloadMessagesOnly; private boolean pendingReloadForcedScrollToBottom; private volatile boolean activityDestroyed; + private volatile boolean markConversationReadInFlight; + private @Nullable AlertDialog activeDialogGuardDialog; + private @Nullable String activeDialogGuardInterventionId; + private boolean conversationReadMarkedSinceResume; private final Runnable conversationAutoRefreshRunnable = new Runnable() { @Override public void run() { @@ -137,6 +161,22 @@ public class ProjectDetailActivity extends BossScreenActivity { syncChatViewportForComposer(); } }; + private static final MentionCandidate[] MENTION_CANDIDATES = new MentionCandidate[]{ + new MentionCandidate("主Agent", "@主Agent", "只和主 Agent 对话,不转发给子线程"), + new MentionCandidate("审计Agent", "@审计Agent", "呼叫审计协作 Agent,适合核对风险和证据") + }; + + private static final class MentionCandidate { + final String label; + final String insertText; + final String description; + + MentionCandidate(String label, String insertText, String description) { + this.label = label; + this.insertText = insertText; + this.description = description; + } + } static final class ChromeBindings { final boolean multiSelecting; @@ -144,6 +184,7 @@ public class ProjectDetailActivity extends BossScreenActivity { final boolean showMultiSelectBar; final boolean showRefresh; final boolean showHeaderAction; + final boolean enableCopyButton; final boolean enableForwardButton; final boolean enablePullRefresh; final String backLabel; @@ -156,6 +197,7 @@ public class ProjectDetailActivity extends BossScreenActivity { boolean showMultiSelectBar, boolean showRefresh, boolean showHeaderAction, + boolean enableCopyButton, boolean enableForwardButton, boolean enablePullRefresh, String backLabel, @@ -167,6 +209,7 @@ public class ProjectDetailActivity extends BossScreenActivity { this.showMultiSelectBar = showMultiSelectBar; this.showRefresh = showRefresh; this.showHeaderAction = showHeaderAction; + this.enableCopyButton = enableCopyButton; this.enableForwardButton = enableForwardButton; this.enablePullRefresh = enablePullRefresh; this.backLabel = backLabel; @@ -200,11 +243,14 @@ public class ProjectDetailActivity extends BossScreenActivity { quickActionsLayout = findViewById(R.id.project_chat_quick_actions); composerRow = findViewById(R.id.project_chat_composer_row); multiSelectActionsLayout = findViewById(R.id.project_chat_multi_select_actions); + mentionSuggestionsPanel = findViewById(R.id.project_chat_mention_panel); composerAttachmentButton = findViewById(R.id.project_chat_attach); composerInput = findViewById(R.id.project_chat_input); composerSendButton = findViewById(R.id.project_chat_send); + multiSelectCopyButton = findViewById(R.id.project_chat_multi_copy); multiSelectForwardButton = findViewById(R.id.project_chat_multi_forward); chatScrollView = findViewById(R.id.project_chat_scroll); + scrollBottomButton = findViewById(R.id.project_chat_scroll_bottom); conversationInfoLauncher = registerForActivityResult( new ActivityResultContracts.StartActivityForResult(), result -> { @@ -256,6 +302,16 @@ public class ProjectDetailActivity extends BossScreenActivity { reload(true); } ); + getOnBackPressedDispatcher().addCallback(this, new OnBackPressedCallback(true) { + @Override + public void handleOnBackPressed() { + if (isMultiSelectingMessages()) { + exitMultiSelect(); + return; + } + finish(); + } + }); imagePickerLauncher = registerForActivityResult( new ActivityResultContracts.GetContent(), uri -> onAttachmentPicked(uri, "image") @@ -283,12 +339,14 @@ public class ProjectDetailActivity extends BossScreenActivity { BossWindowInsets.applyKeyboardAvoidingInset(composerRow); BossWindowInsets.applyKeyboardAvoidingInset(multiSelectActionsLayout); bindChatRefreshScrollBridge(); + bindScrollBottomShortcut(); updateProjectHeader(initialProjectName == null ? "项目详情" : initialProjectName, "正在同步项目详情..."); if (composerAttachmentButton != null) { composerAttachmentButton.setOnClickListener(v -> showAttachmentEntrySheet()); } composerSendButton.setOnClickListener(v -> sendTextMessageFromComposer()); + multiSelectCopyButton.setOnClickListener(v -> copySelectedMessages()); multiSelectForwardButton.setOnClickListener(v -> { if (!ProjectChatUiState.canForwardSelection(selectionState)) { showMessage("至少选择两条消息后才能合并转发"); @@ -306,7 +364,12 @@ public class ProjectDetailActivity extends BossScreenActivity { } @Override - public void afterTextChanged(Editable s) {} + public void afterTextChanged(Editable s) { + if (!suppressMentionSuggestionUpdate) { + updateMentionSuggestions(); + composerInput.post(ProjectDetailActivity.this::updateMentionSuggestions); + } + } }); bindComposerViewportSync(); updateComposerSendButtonState(); @@ -319,6 +382,7 @@ public class ProjectDetailActivity extends BossScreenActivity { @Override protected void onDestroy() { activityDestroyed = true; + closeDialogGuardIntervention(""); cancelConversationAutoRefresh(); cancelRealtimeReloadSchedule(); stopRealtimeUpdates(); @@ -330,6 +394,14 @@ public class ProjectDetailActivity extends BossScreenActivity { @Override protected void onResume() { super.onResume(); + if (getApplication() instanceof BossApplication) { + BossApplication app = (BossApplication) getApplication(); + app.visibilityTracker().setVisibleProjectId(projectId); + if (isMasterAgentConversation()) { + app.notificationRouter().clearMasterAgentNotification(); + } + } + conversationReadMarkedSinceResume = false; conversationAutoRefreshEnabled = true; updateConversationAutoRefresh(); updateRealtimeSubscription(); @@ -338,6 +410,9 @@ public class ProjectDetailActivity extends BossScreenActivity { @Override protected void onPause() { + if (getApplication() instanceof BossApplication) { + ((BossApplication) getApplication()).visibilityTracker().clearVisibleProjectId(projectId); + } conversationAutoRefreshEnabled = false; cancelConversationAutoRefresh(); cancelRealtimeReloadSchedule(); @@ -367,10 +442,62 @@ public class ProjectDetailActivity extends BossScreenActivity { }); } + private void bindScrollBottomShortcut() { + if (scrollBottomButton != null) { + scrollBottomButton.setOnClickListener(v -> { + scrollChatToBottom(); + uiHandler.post(this::updateScrollBottomShortcutVisibility); + }); + } + if (chatScrollView != null) { + chatScrollView.getViewTreeObserver().addOnScrollChangedListener(this::handleChatScrollChanged); + chatScrollView.post(() -> { + lastObservedChatScrollY = chatScrollView.getScrollY(); + updateScrollBottomShortcutVisibility(); + }); + } + } + + private void handleChatScrollChanged() { + if (chatScrollView == null) { + return; + } + int currentScrollY = chatScrollView.getScrollY(); + if (lastObservedChatScrollY == Integer.MIN_VALUE) { + lastObservedChatScrollY = currentScrollY; + } + updateScrollBottomShortcutVisibility(currentScrollY, lastObservedChatScrollY); + lastObservedChatScrollY = currentScrollY; + } + + private void updateScrollBottomShortcutVisibility() { + if (chatScrollView == null) { + return; + } + int currentScrollY = chatScrollView.getScrollY(); + if (lastObservedChatScrollY == Integer.MIN_VALUE) { + lastObservedChatScrollY = currentScrollY; + } + updateScrollBottomShortcutVisibility(currentScrollY, lastObservedChatScrollY); + lastObservedChatScrollY = currentScrollY; + } + + private void updateScrollBottomShortcutVisibility(int currentScrollY, int previousScrollY) { + if (scrollBottomButton == null) { + return; + } + scrollBottomButton.setVisibility( + shouldShowScrollBottomShortcut(currentScrollY, previousScrollY) ? View.VISIBLE : View.GONE + ); + } + void handleRealtimeEvent(BossRealtimeEvent event) { if (event == null || event.eventName.isEmpty() || projectId == null || projectId.isEmpty()) { return; } + if (handleDialogGuardRealtimeEvent(event)) { + return; + } boolean shouldReload = shouldReloadForRealtimeEvent(event); if (!shouldReload) { return; @@ -389,6 +516,29 @@ public class ProjectDetailActivity extends BossScreenActivity { runOnUiThread(() -> scheduleRealtimeReload(!"project.messages.updated".equals(event.eventName))); } + private boolean handleDialogGuardRealtimeEvent(BossRealtimeEvent event) { + if ("desktop.dialog_guard.intervention_required".equals(event.eventName)) { + if (!isDialogGuardEventForCurrentProject(event)) { + return true; + } + runOnUiThread(() -> showDialogGuardIntervention(event.payload)); + return true; + } + if ("desktop.dialog_guard.intervention_resolved".equals(event.eventName)) { + if (!isDialogGuardEventForCurrentProject(event)) { + return true; + } + runOnUiThread(() -> closeDialogGuardIntervention(event.payload.optString("interventionId", ""))); + return true; + } + return false; + } + + private boolean isDialogGuardEventForCurrentProject(BossRealtimeEvent event) { + String payloadProjectId = event.payload.optString("projectId", "").trim(); + return payloadProjectId.isEmpty() || payloadProjectId.equals(projectId); + } + private boolean tryApplyRealtimeMessagesPatch(BossRealtimeEvent event) { if (event == null || !"project.messages.updated".equals(event.eventName)) { return false; @@ -466,6 +616,9 @@ public class ProjectDetailActivity extends BossScreenActivity { if (currentIds.isEmpty() || nextIds.size() <= currentIds.size()) { return false; } + if (ProjectChatUiState.hasThreadProcessFoldCandidates(nextMessages, currentIds.size() - 1)) { + return false; + } for (int i = 0; i < currentIds.size(); i++) { if (!currentIds.get(i).equals(nextIds.get(i))) { return false; @@ -486,6 +639,7 @@ public class ProjectDetailActivity extends BossScreenActivity { projectCollaborationMode = project == null ? "development" : project.optString("collaborationMode", projectCollaborationMode); projectApprovalState = project == null ? "not_required" : project.optString("approvalState", projectApprovalState); lightDispatchReminderEnabled = project != null && project.optBoolean("lightDispatchReminderEnabled", lightDispatchReminderEnabled); + syncProjectDeviceAvatarContext(project, devices); updateProjectHeader(title, buildProjectSubtitle(projectFolderName, devices)); selectionState = ProjectChatUiState.reconcileSelection(selectionState, nextIds); @@ -540,6 +694,249 @@ public class ProjectDetailActivity extends BossScreenActivity { || "master_agent.task.updated".equals(event.eventName); } + private void showDialogGuardIntervention(JSONObject payload) { + if (payload == null || isFinishing() || isDestroyed()) { + return; + } + String interventionId = payload.optString("interventionId", "").trim(); + if (interventionId.isEmpty()) { + return; + } + closeDialogGuardIntervention(""); + activeDialogGuardInterventionId = interventionId; + activeDialogGuardDialog = new AlertDialog.Builder(this) + .setView(buildDialogGuardInterventionView(payload)) + .create(); + activeDialogGuardDialog.setOnDismissListener(dialog -> { + if (TextUtils.equals(activeDialogGuardInterventionId, interventionId)) { + activeDialogGuardInterventionId = null; + activeDialogGuardDialog = null; + } + }); + activeDialogGuardDialog.show(); + } + + private View buildDialogGuardInterventionView(JSONObject payload) { + LinearLayout card = new LinearLayout(this); + card.setOrientation(LinearLayout.VERTICAL); + int padding = BossUi.dp(this, 18); + card.setPadding(padding, padding, padding, padding); + + TextView kicker = new TextView(this); + kicker.setText("桌面弹窗确认"); + kicker.setTextSize(13); + kicker.setTextColor(getColor(R.color.boss_text_muted)); + card.addView(kicker); + + TextView title = new TextView(this); + title.setText(dialogGuardTitle(payload)); + title.setTextSize(18); + title.setTypeface(Typeface.DEFAULT_BOLD); + title.setTextColor(getColor(R.color.boss_text_primary)); + LinearLayout.LayoutParams titleParams = new LinearLayout.LayoutParams( + LinearLayout.LayoutParams.MATCH_PARENT, + LinearLayout.LayoutParams.WRAP_CONTENT + ); + titleParams.topMargin = BossUi.dp(this, 6); + card.addView(title, titleParams); + + TextView summary = new TextView(this); + summary.setText(payload.optString("summary", "绑定电脑上有一个弹窗需要你确认。")); + summary.setTextSize(15); + summary.setTextColor(getColor(R.color.boss_text_primary)); + summary.setLineSpacing(BossUi.dp(this, 2), 1.0f); + LinearLayout.LayoutParams summaryParams = new LinearLayout.LayoutParams( + LinearLayout.LayoutParams.MATCH_PARENT, + LinearLayout.LayoutParams.WRAP_CONTENT + ); + summaryParams.topMargin = BossUi.dp(this, 10); + card.addView(summary, summaryParams); + + TextView meta = new TextView(this); + meta.setText(dialogGuardMeta(payload)); + meta.setTextSize(13); + meta.setTextColor(getColor(R.color.boss_text_muted)); + LinearLayout.LayoutParams metaParams = new LinearLayout.LayoutParams( + LinearLayout.LayoutParams.MATCH_PARENT, + LinearLayout.LayoutParams.WRAP_CONTENT + ); + metaParams.topMargin = BossUi.dp(this, 8); + card.addView(meta, metaParams); + + LinearLayout actions = new LinearLayout(this); + actions.setOrientation(LinearLayout.VERTICAL); + LinearLayout.LayoutParams actionsParams = new LinearLayout.LayoutParams( + LinearLayout.LayoutParams.MATCH_PARENT, + LinearLayout.LayoutParams.WRAP_CONTENT + ); + actionsParams.topMargin = BossUi.dp(this, 14); + card.addView(actions, actionsParams); + + List decisions = dialogGuardAllowedDecisions(payload); + for (String decision : decisions) { + Button button = new Button(this); + button.setAllCaps(false); + button.setText(dialogGuardDecisionLabel(decision)); + button.setTextSize(15); + button.setTextColor(getColor("deny".equals(decision) || "cancel_task".equals(decision) + ? android.R.color.holo_red_dark + : R.color.boss_text_primary)); + button.setGravity(Gravity.CENTER); + button.setBackgroundResource("allow_once".equals(decision) || "handled_on_device".equals(decision) + ? R.drawable.bg_primary_button + : R.drawable.bg_secondary_button); + if ("allow_once".equals(decision) || "handled_on_device".equals(decision)) { + button.setTextColor(getColor(R.color.boss_surface)); + } + button.setOnClickListener(view -> submitDialogGuardDecision(payload.optString("interventionId", ""), decision)); + LinearLayout.LayoutParams buttonParams = new LinearLayout.LayoutParams( + LinearLayout.LayoutParams.MATCH_PARENT, + LinearLayout.LayoutParams.WRAP_CONTENT + ); + buttonParams.topMargin = BossUi.dp(this, 8); + actions.addView(button, buttonParams); + } + return card; + } + + private String dialogGuardTitle(JSONObject payload) { + String appName = payload.optString("appName", "").trim(); + if (appName.isEmpty()) { + return "电脑弹窗需要确认"; + } + return appName; + } + + private String dialogGuardMeta(JSONObject payload) { + String risk = payload.optString("risk", "").trim(); + String deviceId = payload.optString("deviceId", "").trim(); + String platform = payload.optString("platform", "").trim(); + StringBuilder builder = new StringBuilder(); + if (!risk.isEmpty()) { + builder.append("风险:").append(dialogGuardRiskLabel(risk)); + } + if (!deviceId.isEmpty()) { + if (builder.length() > 0) { + builder.append(" · "); + } + builder.append(deviceId); + } + if (!platform.isEmpty()) { + if (builder.length() > 0) { + builder.append(" · "); + } + builder.append(platform); + } + return builder.length() == 0 ? "请先确认电脑上的弹窗内容,再选择动作。" : builder.toString(); + } + + private String dialogGuardRiskLabel(String risk) { + switch (risk) { + case "high": + return "高"; + case "low": + return "低"; + case "blocked": + return "已阻断"; + case "sensitive": + return "敏感"; + case "medium": + return "中等"; + case "safe": + return "低"; + default: + return risk; + } + } + + private List dialogGuardAllowedDecisions(JSONObject payload) { + ArrayList available = new ArrayList<>(); + JSONArray rawActions = payload.optJSONArray("availableActions"); + if (rawActions != null) { + for (int index = 0; index < rawActions.length(); index += 1) { + String action = rawActions.optString(index, "").trim(); + if (!action.isEmpty()) { + available.add(action); + } + } + } + String risk = payload.optString("risk", "").trim(); + String[] preferred = isDialogGuardRestrictedRisk(risk) + ? new String[]{"handled_on_device", "cancel_task"} + : new String[]{"allow_once", "allow_for_device_dialog", "deny"}; + ArrayList decisions = new ArrayList<>(); + for (String decision : preferred) { + if (available.isEmpty() || available.contains(decision)) { + decisions.add(decision); + } + } + return decisions; + } + + private boolean isDialogGuardRestrictedRisk(String risk) { + return "high".equals(risk) || "blocked".equals(risk) || "sensitive".equals(risk); + } + + private String dialogGuardDecisionLabel(String decision) { + switch (decision) { + case "allow_once": + return "允许本次"; + case "allow_for_device_dialog": + return "当前设备此弹窗允许"; + case "deny": + return "拒绝"; + case "handled_on_device": + return "我已在电脑上处理"; + case "cancel_task": + return "取消任务"; + default: + return decision; + } + } + + private void submitDialogGuardDecision(String interventionId, String decision) { + if (interventionId == null || interventionId.trim().isEmpty() || decision == null || decision.trim().isEmpty()) { + return; + } + showMessage("正在提交选择..."); + try { + executor.execute(() -> { + try { + BossApiClient.ApiResponse response = apiClient.decideDialogGuardIntervention(interventionId, decision); + runOnUiThread(() -> { + if (response.ok()) { + showMessage("已提交"); + closeDialogGuardIntervention(interventionId); + } else { + showMessage(response.message()); + } + }); + } catch (Exception error) { + runOnUiThread(() -> showMessage("提交失败:" + error.getMessage())); + } + }); + } catch (RejectedExecutionException ignored) { + showMessage("页面已关闭,无法提交"); + } + } + + private void closeDialogGuardIntervention(String interventionId) { + if (activeDialogGuardDialog == null) { + activeDialogGuardInterventionId = null; + return; + } + if (!TextUtils.isEmpty(interventionId) + && !TextUtils.equals(interventionId, activeDialogGuardInterventionId)) { + return; + } + AlertDialog dialog = activeDialogGuardDialog; + activeDialogGuardDialog = null; + activeDialogGuardInterventionId = null; + if (dialog.isShowing()) { + dialog.dismiss(); + } + } + void triggerRealtimeReload(boolean requireFullSnapshot) { if (requireFullSnapshot) { reload(); @@ -549,9 +946,12 @@ public class ProjectDetailActivity extends BossScreenActivity { } private void scheduleRealtimeReload(boolean requireFullSnapshot) { - realtimeReloadRequiresFullSnapshot = false; - realtimeReloadScheduled = false; - triggerRealtimeReload(requireFullSnapshot); + realtimeReloadRequiresFullSnapshot = realtimeReloadRequiresFullSnapshot || requireFullSnapshot; + if (realtimeReloadScheduled) { + return; + } + realtimeReloadScheduled = true; + uiHandler.postDelayed(realtimeReloadRunnable, REALTIME_REFRESH_DEBOUNCE_MS); } private void cancelRealtimeReloadSchedule() { @@ -641,6 +1041,7 @@ public class ProjectDetailActivity extends BossScreenActivity { void renderLoadedProjectSnapshot(ProjectSnapshot snapshot) { renderProject(snapshot.payload, snapshot.dispatchPlans, snapshot.participantsPayload); + updateScrollBottomShortcutVisibility(); } void handleProjectReloadFailure(Exception error) { @@ -698,6 +1099,7 @@ public class ProjectDetailActivity extends BossScreenActivity { currentReasoningEffortOverride = normalizeControlValue(agentControls == null ? null : agentControls.optString("reasoningEffortOverride", null)); currentFastModelOverride = normalizeControlValue(agentControls == null ? null : agentControls.optString("fastModelOverride", null)); currentDeepModelOverride = normalizeControlValue(agentControls == null ? null : agentControls.optString("deepModelOverride", null)); + syncProjectDeviceAvatarContext(project, devices); if (dispatchPlans != null) { currentPendingDispatchPlan = ProjectChatUiState.latestPendingDispatchPlan(dispatchPlans); currentRejectedDispatchPlan = currentPendingDispatchPlan == null @@ -731,18 +1133,16 @@ public class ProjectDetailActivity extends BossScreenActivity { } if (messages != null && messages.length() > 0) { - for (int i = 0; i < messages.length(); i++) { - JSONObject message = messages.optJSONObject(i); - if (message == null) { - continue; - } - appendContent(buildMessageView(message)); + List displayItems = + ProjectChatUiState.buildMessageDisplayItems(messages); + for (ProjectChatUiState.MessageDisplayItem item : displayItems) { + appendContent(buildMessageDisplayView(item)); } } else { appendContent(BossUi.buildMessagePlaceholder(this, "还没有项目消息,先发一条开始对话。")); } - boolean masterAgentHasReply = isMasterAgentConversation() + boolean masterAgentHasReply = isTrackingMasterAgentReply() && ProjectChatUiState.hasReplyBeyondBaseline(project, masterAgentReplyBaselineMessageId); if (masterAgentHasReply) { clearMasterAgentReplyState(); @@ -757,6 +1157,7 @@ public class ProjectDetailActivity extends BossScreenActivity { }); currentRenderedProjectPayload = copyJson(payload); + maybeMarkConversationRead(project); setRefreshing(false); updateSelectionUi(); if (ProjectChatUiState.shouldAutoScroll(renderNearBottom, renderForcedScrollToBottom)) { @@ -764,6 +1165,24 @@ public class ProjectDetailActivity extends BossScreenActivity { } } + private View buildMessageDisplayView(ProjectChatUiState.MessageDisplayItem item) { + if (item == null) { + return BossUi.buildHintPill(this, "消息暂不可用"); + } + if (ProjectChatUiState.MessageDisplayItem.TYPE_PROCESS_GROUP.equals(item.type)) { + return BossUi.buildThreadProcessFoldCard( + this, + item.processMessages.size(), + ProjectChatUiState.processGroupPreview(item), + ProjectChatUiState.processGroupDetail(item) + ); + } + if (item.message == null) { + return BossUi.buildHintPill(this, "消息暂不可用"); + } + return buildMessageView(item.message); + } + private void runWithSuppressedContentLayout(Runnable action) { if (action == null) { return; @@ -772,6 +1191,10 @@ public class ProjectDetailActivity extends BossScreenActivity { action.run(); return; } + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) { + action.run(); + return; + } contentLayout.suppressLayout(true); try { action.run(); @@ -780,6 +1203,50 @@ public class ProjectDetailActivity extends BossScreenActivity { } } + private void maybeMarkConversationRead(@Nullable JSONObject project) { + if (project == null + || conversationReadMarkedSinceResume + || shouldSkipAsyncUiWork() + || TextUtils.isEmpty(projectId) + || !conversationAutoRefreshEnabled + || project.optInt("unreadCount", 0) <= 0) { + return; + } + requestMarkConversationRead(true); + } + + private void requestMarkConversationRead(boolean markSessionHandled) { + if (markConversationReadInFlight || shouldSkipAsyncUiWork() || TextUtils.isEmpty(projectId)) { + return; + } + final String targetProjectId = projectId; + markConversationReadInFlight = true; + if (markSessionHandled) { + conversationReadMarkedSinceResume = true; + } + try { + executor.execute(() -> { + boolean success = false; + try { + BossApiClient.ApiResponse response = apiClient.markConversationRead(targetProjectId); + success = response != null && response.ok(); + } catch (Exception ignored) { + success = false; + } finally { + if (!success && markSessionHandled) { + conversationReadMarkedSinceResume = false; + } + markConversationReadInFlight = false; + } + }); + } catch (RejectedExecutionException ignored) { + if (markSessionHandled) { + conversationReadMarkedSinceResume = false; + } + markConversationReadInFlight = false; + } + } + private void updateRealtimeSubscription() { if (apiClient != null && apiClient.hasSessionHints()) { realtimeClient.start(); @@ -817,7 +1284,8 @@ public class ProjectDetailActivity extends BossScreenActivity { } private boolean shouldAutoRefreshConversation() { - return shouldMaintainConversationAutoRefresh() && !isRealtimeConnected(); + return shouldMaintainConversationAutoRefresh() + && (!isRealtimeConnected() || shouldKeepPollingTrackedMasterReply()); } private boolean isRealtimeConnected() { @@ -899,10 +1367,16 @@ public class ProjectDetailActivity extends BossScreenActivity { if (composerInput != null) { composerInput.setOnFocusChangeListener((v, hasFocus) -> { if (hasFocus) { + updateMentionSuggestions(); scheduleComposerViewportSync(); + } else { + hideMentionSuggestions(); } }); - composerInput.setOnClickListener(v -> scheduleComposerViewportSync()); + composerInput.setOnClickListener(v -> { + updateMentionSuggestions(); + scheduleComposerViewportSync(); + }); } if (composerRow != null) { composerRow.addOnLayoutChangeListener((v, left, top, right, bottom, oldLeft, oldTop, oldRight, oldBottom) -> { @@ -916,6 +1390,149 @@ public class ProjectDetailActivity extends BossScreenActivity { } } + private void updateMentionSuggestions() { + if (mentionSuggestionsPanel == null || composerInput == null) { + return; + } + if (!composerInput.isFocused() + || composerRow == null + || composerRow.getVisibility() != View.VISIBLE + || !composerInput.isEnabled()) { + hideMentionSuggestions(); + return; + } + if (currentMentionTokenStart() < 0) { + hideMentionSuggestions(); + return; + } + showMentionSuggestions(); + } + + private void showMentionSuggestions() { + if (mentionSuggestionsPanel == null) { + return; + } + mentionSuggestionsPanel.removeAllViews(); + for (MentionCandidate candidate : MENTION_CANDIDATES) { + mentionSuggestionsPanel.addView(buildMentionSuggestionRow(candidate)); + } + mentionSuggestionsPanel.setVisibility(View.VISIBLE); + } + + private View buildMentionSuggestionRow(MentionCandidate candidate) { + LinearLayout row = new LinearLayout(this); + row.setOrientation(LinearLayout.HORIZONTAL); + row.setGravity(Gravity.CENTER_VERTICAL); + row.setPadding(BossUi.dp(this, 12), BossUi.dp(this, 10), BossUi.dp(this, 12), BossUi.dp(this, 10)); + row.setBackgroundResource(R.drawable.bg_list_row); + row.setClickable(true); + row.setFocusable(true); + row.setContentDescription(candidate.label); + row.setOnClickListener(v -> insertMentionCandidate(candidate)); + + TextView avatar = new TextView(this); + LinearLayout.LayoutParams avatarParams = new LinearLayout.LayoutParams(BossUi.dp(this, 36), BossUi.dp(this, 36)); + avatarParams.rightMargin = BossUi.dp(this, 10); + avatar.setLayoutParams(avatarParams); + avatar.setGravity(Gravity.CENTER); + avatar.setText(candidate.label.startsWith("主") ? "主" : "审"); + avatar.setTextSize(15); + avatar.setTypeface(Typeface.DEFAULT_BOLD); + avatar.setTextColor(candidate.label.startsWith("主") ? getColor(R.color.boss_green) : getColor(R.color.boss_text_primary)); + GradientDrawable avatarBg = new GradientDrawable(); + avatarBg.setShape(GradientDrawable.OVAL); + avatarBg.setColor(candidate.label.startsWith("主") ? Color.parseColor("#E4F7EC") : Color.parseColor("#EEF3F8")); + avatar.setBackground(avatarBg); + row.addView(avatar); + + LinearLayout textWrap = new LinearLayout(this); + textWrap.setOrientation(LinearLayout.VERTICAL); + textWrap.setLayoutParams(new LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f)); + + TextView title = new TextView(this); + title.setText(candidate.label); + title.setTextSize(16); + title.setTypeface(Typeface.DEFAULT_BOLD); + title.setTextColor(getColor(R.color.boss_text_primary)); + textWrap.addView(title); + + TextView subtitle = new TextView(this); + subtitle.setText(candidate.description); + subtitle.setTextSize(12); + subtitle.setTextColor(getColor(R.color.boss_text_muted)); + subtitle.setPadding(0, BossUi.dp(this, 3), 0, 0); + textWrap.addView(subtitle); + row.addView(textWrap); + return row; + } + + private void insertMentionCandidate(MentionCandidate candidate) { + if (composerInput == null) { + return; + } + Editable text = composerInput.getText(); + if (text == null) { + return; + } + int tokenStart = currentMentionTokenStart(); + int cursor = composerInput.getSelectionStart(); + if (tokenStart < 0 || cursor < 0) { + tokenStart = Math.max(0, cursor); + } + int replaceEnd = findMentionTokenEnd(text, Math.max(tokenStart, cursor)); + String replacement = candidate.insertText + " "; + suppressMentionSuggestionUpdate = true; + try { + text.replace(tokenStart, replaceEnd, replacement); + int nextCursor = tokenStart + replacement.length(); + composerInput.setSelection(Math.min(nextCursor, text.length())); + } finally { + suppressMentionSuggestionUpdate = false; + } + hideMentionSuggestions(); + updateComposerSendButtonState(); + } + + private int currentMentionTokenStart() { + if (composerInput == null) { + return -1; + } + Editable text = composerInput.getText(); + if (text == null) { + return -1; + } + int cursor = composerInput.getSelectionStart(); + int selectionEnd = composerInput.getSelectionEnd(); + if (cursor < 0 || selectionEnd < 0 || cursor != selectionEnd) { + return -1; + } + cursor = Math.min(cursor, text.length()); + if (cursor == 0) { + return -1; + } + int tokenStart = cursor - 1; + while (tokenStart >= 0 && !Character.isWhitespace(text.charAt(tokenStart))) { + tokenStart -= 1; + } + tokenStart += 1; + return tokenStart < cursor && text.charAt(tokenStart) == '@' ? tokenStart : -1; + } + + private int findMentionTokenEnd(Editable text, int start) { + int end = Math.min(Math.max(start, 0), text.length()); + while (end < text.length() && !Character.isWhitespace(text.charAt(end))) { + end += 1; + } + return end; + } + + private void hideMentionSuggestions() { + if (mentionSuggestionsPanel != null) { + mentionSuggestionsPanel.removeAllViews(); + mentionSuggestionsPanel.setVisibility(View.GONE); + } + } + private void scheduleComposerViewportSync() { uiHandler.removeCallbacks(composerViewportSyncRunnable); uiHandler.post(composerViewportSyncRunnable); @@ -1127,6 +1744,7 @@ public class ProjectDetailActivity extends BossScreenActivity { } appendMessageIfMissing(messages, sentMessage); appendMessageIfMissing(messages, replyMessage); + appendMessageIfMissing(messages, buildControlSummaryMessageIfNeeded(response)); removePendingOutgoingBubble(); clearMasterAgentReplyState(); renderNearBottom = true; @@ -1135,6 +1753,54 @@ public class ProjectDetailActivity extends BossScreenActivity { return true; } + @Nullable + private JSONObject buildControlSummaryMessageIfNeeded(@Nullable JSONObject response) { + if (response == null) { + return null; + } + String executionMode = response.optString("executionMode", "").trim(); + if (!"browser".equals(executionMode) && !"desktop".equals(executionMode)) { + return null; + } + JSONObject replyMessage = response.optJSONObject("replyMessage"); + if (replyMessage == null) { + return null; + } + String summaryBody = replyMessage.optString("body", "").trim(); + if (summaryBody.isEmpty()) { + return null; + } + + String controlTarget = + "browser".equals(executionMode) + ? response.optString("targetUrl", "").trim() + : response.optString("targetApp", "").trim(); + String riskLevel = response.optString("riskLevel", "").trim(); + boolean requiresConfirmation = response.optBoolean("requiresConfirmation", false); + + JSONObject controlMessage = new JSONObject(); + try { + controlMessage.put("id", replyMessage.optString("id", "") + "-control-summary"); + controlMessage.put("sender", "system"); + controlMessage.put("senderLabel", "执行摘要"); + controlMessage.put("body", summaryBody); + controlMessage.put("kind", "control_summary"); + controlMessage.put("sentAt", replyMessage.optString("sentAt", "")); + if (!TextUtils.isEmpty(controlTarget)) { + controlMessage.put("controlTarget", controlTarget); + } + if (!TextUtils.isEmpty(riskLevel)) { + controlMessage.put("riskLevel", riskLevel); + } + if (requiresConfirmation) { + controlMessage.put("requiresConfirmation", true); + } + } catch (Exception ignored) { + return null; + } + return controlMessage; + } + private void appendMessageIfMissing(JSONArray messages, JSONObject message) { if (messages == null || message == null) { return; @@ -1294,7 +1960,7 @@ public class ProjectDetailActivity extends BossScreenActivity { startActivity(intent); } - private void openConversationInfo() { + void openConversationInfo() { if (projectId == null || projectId.isEmpty()) { showMessage("缺少 projectId"); return; @@ -1374,24 +2040,6 @@ public class ProjectDetailActivity extends BossScreenActivity { .show(); } - private void showConversationMoreMenu() { - new AlertDialog.Builder(this) - .setItems(new CharSequence[]{"会话信息", "刷新"}, (dialog, which) -> { - switch (which) { - case 0: - openConversationInfo(); - break; - case 1: - reload(true); - break; - default: - dialog.dismiss(); - break; - } - }) - .show(); - } - private void showMasterAgentModelPicker() { if (!isMasterAgentConversation()) { return; @@ -1743,13 +2391,15 @@ public class ProjectDetailActivity extends BossScreenActivity { String body = message.optString("body", ""); String meta = formatMessageTime(message.optString("sentAt", "")); String kind = message.optString("kind", ""); + String displaySenderLabel = displaySenderLabelForMessage(senderLabel, sender); boolean outgoing = isOutgoingMessage(senderLabel, sender); + boolean masterAgentMessage = isMasterAgentMessage(senderLabel, sender); View messageView; View.OnClickListener messagePrimaryClick = null; switch (kind) { case "attachment": - messageView = buildAttachmentMessageView(message, senderLabel, meta, outgoing); + messageView = buildAttachmentMessageView(message, displaySenderLabel, meta, outgoing, masterAgentMessage); JSONObject attachment = firstAttachment(message); if (attachment != null) { String attachmentId = attachment.optString("attachmentId", ""); @@ -1758,20 +2408,51 @@ public class ProjectDetailActivity extends BossScreenActivity { } } break; - case "forward_single": - messageView = BossUi.buildForwardSingleBubble( + case "control_summary": + String controlTarget = message.optString("controlTarget", "").trim(); + String controlBody = body; + if (!TextUtils.isEmpty(controlTarget)) { + controlBody = body + "\n\n目标:" + controlTarget; + } + messageView = BossUi.buildControlSummaryCard( this, - senderLabel, - body, + displaySenderLabel, + controlBody, meta, - resolveForwardSingleSourceLabel(message), - outgoing + labelForMessageKind(kind) ); break; + case "execution_progress": + messageView = BossUi.buildExecutionProgressCard( + this, + message.optJSONObject("executionProgress"), + meta + ); + break; + case "forward_single": + if (masterAgentMessage) { + messageView = BossUi.buildMasterAgentForwardSingleBubble( + this, + displaySenderLabel, + body, + meta, + resolveForwardSingleSourceLabel(message) + ); + } else { + messageView = BossUi.buildForwardSingleBubble( + this, + displaySenderLabel, + body, + meta, + resolveForwardSingleSourceLabel(message), + outgoing + ); + } + break; case "forward_bundle": messageView = BossUi.buildForwardBundleCard( this, - senderLabel, + displaySenderLabel, resolveForwardBundleTitle(message), resolveForwardBundleSummary(message), meta, @@ -1779,18 +2460,37 @@ public class ProjectDetailActivity extends BossScreenActivity { ); break; default: - messageView = BossUi.buildMessageBubble( - this, - senderLabel, - body, - meta, - outgoing, - labelForMessageKind(kind) - ); + if (masterAgentMessage) { + messageView = BossUi.buildMasterAgentMessageBubble( + this, + displaySenderLabel, + body, + meta, + labelForMessageKind(kind) + ); + } else { + messageView = BossUi.buildMessageBubble( + this, + displaySenderLabel, + body, + meta, + outgoing, + labelForMessageKind(kind) + ); + } break; } + MessageSourceAvatar sourceAvatar = resolveMessageSourceAvatar(message, kind, outgoing); + if (sourceAvatar != null) { + messageView = BossUi.wrapIncomingMessageWithSourceAvatar( + this, + messageView, + sourceAvatar.avatarLabel, + sourceAvatar.sourceName + ); + } bindMessageInteractions(messageView, messageId, body, messagePrimaryClick); - return messageView; + return decorateMessageSelectionIndicator(messageView, messageId); } private View buildAttachmentMessageView( @@ -1798,17 +2498,29 @@ public class ProjectDetailActivity extends BossScreenActivity { String senderLabel, String meta, boolean outgoing + ) { + return buildAttachmentMessageView(message, senderLabel, meta, outgoing, false); + } + + private View buildAttachmentMessageView( + JSONObject message, + String senderLabel, + String meta, + boolean outgoing, + boolean masterAgentMessage ) { JSONObject attachment = firstAttachment(message); if (attachment == null) { - return BossUi.buildMessageBubble( - this, - senderLabel, - message.optString("body", "已发送附件"), - meta, - outgoing, - "附件" - ); + if (masterAgentMessage) { + return BossUi.buildMasterAgentMessageBubble( + this, + senderLabel, + message.optString("body", "已发送附件"), + meta, + "附件" + ); + } + return BossUi.buildMessageBubble(this, senderLabel, message.optString("body", "已发送附件"), meta, outgoing, "附件"); } String sourceType = resolveAttachmentSourceType( attachment.optString("attachmentKind", ""), @@ -1839,6 +2551,9 @@ public class ProjectDetailActivity extends BossScreenActivity { outgoing, null ); + if (masterAgentMessage) { + tintMasterAgentCard(attachmentView); + } refineAttachmentMessageCard(attachmentView, analysisState, summary, actionLabel, actionListener); return attachmentView; } @@ -2064,6 +2779,107 @@ public class ProjectDetailActivity extends BossScreenActivity { ); } + private View decorateMessageSelectionIndicator(View messageView, String messageId) { + if (messageView == null || TextUtils.isEmpty(messageId)) { + return messageView; + } + LinearLayout row = new LinearLayout(this); + row.setOrientation(LinearLayout.HORIZONTAL); + row.setGravity(Gravity.TOP); + LinearLayout.LayoutParams rowParams = new LinearLayout.LayoutParams( + LinearLayout.LayoutParams.MATCH_PARENT, + LinearLayout.LayoutParams.WRAP_CONTENT + ); + row.setLayoutParams(rowParams); + + TextView indicator = buildMultiSelectIndicator(messageId); + row.addView(indicator); + + LinearLayout.LayoutParams messageParams = new LinearLayout.LayoutParams( + 0, + LinearLayout.LayoutParams.WRAP_CONTENT, + 1f + ); + messageView.setLayoutParams(messageParams); + row.addView(messageView); + return row; + } + + private TextView buildMultiSelectIndicator(String messageId) { + TextView indicator = new TextView(this); + LinearLayout.LayoutParams params = new LinearLayout.LayoutParams( + BossUi.dp(this, 22), + BossUi.dp(this, 22) + ); + params.topMargin = BossUi.dp(this, 18); + params.rightMargin = BossUi.dp(this, 8); + indicator.setLayoutParams(params); + indicator.setGravity(Gravity.CENTER); + indicator.setTextSize(12); + indicator.setTypeface(Typeface.DEFAULT_BOLD); + indicator.setTag("selection-indicator-" + messageId); + bindSelectionIndicator(indicator, selectionState.selectedMessageIds.contains(messageId)); + return indicator; + } + + private void bindSelectionIndicator(TextView indicator, boolean selected) { + if (indicator == null) { + return; + } + boolean visible = selectionState != null && selectionState.multiSelecting; + indicator.setVisibility(visible ? View.VISIBLE : View.GONE); + indicator.setText(selected ? "✓" : ""); + indicator.setTextColor(selected ? Color.WHITE : getColor(R.color.boss_green)); + GradientDrawable drawable = new GradientDrawable(); + drawable.setShape(GradientDrawable.OVAL); + drawable.setColor(selected ? getColor(R.color.boss_green) : Color.TRANSPARENT); + drawable.setStroke(BossUi.dp(this, 1), selected ? getColor(R.color.boss_green) : Color.parseColor("#C8D1CB")); + indicator.setBackground(drawable); + } + + private void tintMasterAgentCard(@Nullable View attachmentView) { + if (!(attachmentView instanceof LinearLayout)) { + return; + } + LinearLayout wrapper = (LinearLayout) attachmentView; + if (wrapper.getChildCount() < 2) { + return; + } + View card = wrapper.getChildAt(1); + GradientDrawable background = new GradientDrawable(); + background.setCornerRadius(BossUi.dp(this, 18)); + background.setColor(Color.parseColor("#EAF5FF")); + background.setStroke(BossUi.dp(this, 1), Color.parseColor("#D1E8FF")); + card.setBackground(background); + } + + private void refreshMessageSelectionDecorations(@Nullable View root) { + if (root == null) { + return; + } + Object tag = root.getTag(); + if (tag instanceof String) { + String tagValue = (String) tag; + if (tagValue.startsWith("selection-indicator-") && root instanceof TextView) { + String messageId = tagValue.substring("selection-indicator-".length()); + bindSelectionIndicator((TextView) root, selectionState.selectedMessageIds.contains(messageId)); + } else { + BossUi.applyMessageSelectionState( + this, + root, + selectionState.selectedMessageIds.contains(tagValue) + ); + } + } + if (!(root instanceof ViewGroup)) { + return; + } + ViewGroup group = (ViewGroup) root; + for (int i = 0; i < group.getChildCount(); i++) { + refreshMessageSelectionDecorations(group.getChildAt(i)); + } + } + private void showMessageActions(String messageId, String body) { new AlertDialog.Builder(this) .setTitle("消息操作") @@ -2079,7 +2895,7 @@ public class ProjectDetailActivity extends BossScreenActivity { copyMessageBody(body); break; case 3: - showMessage("删除消息能力暂未接通"); + confirmDeleteMessage(messageId); break; default: dialog.dismiss(); @@ -2089,6 +2905,40 @@ public class ProjectDetailActivity extends BossScreenActivity { .show(); } + private void confirmDeleteMessage(String messageId) { + if (TextUtils.isEmpty(messageId)) { + showMessage("缺少消息 ID"); + return; + } + new AlertDialog.Builder(this) + .setTitle("删除消息") + .setMessage("删除后会从当前会话记录中移除。") + .setNegativeButton("取消", null) + .setPositiveButton("删除", (dialog, which) -> deleteMessage(messageId)) + .show(); + } + + private void deleteMessage(String messageId) { + setRefreshing(true); + executor.execute(() -> { + try { + BossApiClient.ApiResponse response = apiClient.deleteProjectMessage(projectId, messageId); + if (!response.ok()) { + throw new IllegalStateException(response.message()); + } + runOnUiThread(() -> { + showMessage("消息已删除"); + reloadMessagesOnly(); + }); + } catch (Exception error) { + runOnUiThread(() -> { + setRefreshing(false); + showMessage("删除失败:" + error.getMessage()); + }); + } + }); + } + private void copyMessageBody(String body) { ClipboardManager clipboard = (ClipboardManager) getSystemService(CLIPBOARD_SERVICE); if (clipboard == null) { @@ -2099,6 +2949,63 @@ public class ProjectDetailActivity extends BossScreenActivity { showMessage("已复制消息"); } + private void copySelectedMessages() { + if (!ProjectChatUiState.canCopySelection(selectionState)) { + showMessage("请选择要复制的消息"); + return; + } + String transcript = buildSelectedMessagesTranscript(); + if (TextUtils.isEmpty(transcript)) { + showMessage("没有可复制的消息"); + return; + } + ClipboardManager clipboard = (ClipboardManager) getSystemService(CLIPBOARD_SERVICE); + if (clipboard == null) { + showMessage("当前设备不支持复制"); + return; + } + clipboard.setPrimaryClip(ClipData.newPlainText("boss-chat-messages", transcript)); + showMessage("已复制 " + selectionState.selectedMessageIds.size() + " 条消息"); + exitMultiSelect(); + } + + private String buildSelectedMessagesTranscript() { + JSONObject project = currentRenderedProjectPayload == null + ? null + : currentRenderedProjectPayload.optJSONObject("project"); + JSONArray messages = project == null ? null : project.optJSONArray("messages"); + if (messages == null || messages.length() == 0) { + return ""; + } + List lines = new ArrayList<>(); + for (int i = 0; i < messages.length(); i++) { + JSONObject message = messages.optJSONObject(i); + if (message == null) { + continue; + } + String messageId = message.optString("id", ""); + if (!selectionState.selectedMessageIds.contains(messageId)) { + continue; + } + lines.add(formatMessageForCopy(message)); + } + return TextUtils.join("\n\n", lines); + } + + private String formatMessageForCopy(JSONObject message) { + String speaker = displaySenderLabelForMessage( + message.optString("senderLabel", ""), + message.optString("sender", "") + ); + String time = formatMessageTime(message.optString("sentAt", "")); + String body = message.optString("body", ""); + if (TextUtils.isEmpty(body)) { + body = labelForMessageKind(message.optString("kind", "")); + } + String prefix = TextUtils.isEmpty(time) ? speaker : time + " " + speaker; + return prefix + ":" + (TextUtils.isEmpty(body) ? "" : body.trim()); + } + private void openSingleForwardTarget(String sourceMessageId) { if (TextUtils.isEmpty(sourceMessageId)) { showMessage("缺少消息 ID"); @@ -2136,6 +3043,10 @@ public class ProjectDetailActivity extends BossScreenActivity { updateSelectionUi(); } + private boolean isMultiSelectingMessages() { + return selectionState != null && selectionState.multiSelecting; + } + private void toggleMultiSelectMessage(String messageId) { ProjectChatUiState.SelectionState next = ProjectChatUiState.toggleSelection(selectionState, messageId); if (!next.multiSelecting) { @@ -2154,12 +3065,18 @@ public class ProjectDetailActivity extends BossScreenActivity { currentScreenSubtitle ); ChromeBindings bindings = buildChromeBindings(chromeState, isComposerBusy()); + if (!bindings.showComposer) { + hideMentionSuggestions(); + } if (composerRow != null) { composerRow.setVisibility(bindings.showComposer ? View.VISIBLE : View.GONE); } if (multiSelectActionsLayout != null) { multiSelectActionsLayout.setVisibility(bindings.showMultiSelectBar ? View.VISIBLE : View.GONE); } + if (multiSelectCopyButton != null) { + multiSelectCopyButton.setEnabled(bindings.enableCopyButton); + } if (multiSelectForwardButton != null) { multiSelectForwardButton.setEnabled(bindings.enableForwardButton); } @@ -2187,7 +3104,7 @@ public class ProjectDetailActivity extends BossScreenActivity { } else if (isMasterAgentConversation()) { setHeaderAction("...", v -> showMasterAgentMoreMenu()); } else if (bindings.showHeaderAction) { - setHeaderAction("...", v -> showConversationMoreMenu()); + setHeaderAction("...", v -> openConversationInfo()); } else { hideHeaderAction(); } @@ -2199,12 +3116,7 @@ public class ProjectDetailActivity extends BossScreenActivity { return; } for (int i = 0; i < contentLayout.getChildCount(); i++) { - View child = contentLayout.getChildAt(i); - Object tag = child.getTag(); - boolean selected = tag instanceof String - && selectionState != null - && selectionState.selectedMessageIds.contains(tag); - BossUi.applyMessageSelectionState(this, child, selected); + refreshMessageSelectionDecorations(contentLayout.getChildAt(i)); } } @@ -2307,6 +3219,28 @@ public class ProjectDetailActivity extends BossScreenActivity { return container; } + private boolean shouldTrackMasterAgentReplyInCurrentConversation() { + return !isMasterAgentConversation() && "master".equals(pendingReplyPresenter); + } + + private void beginMasterAgentReplyTracking(@Nullable String baselineMessageId) { + if (TextUtils.isEmpty(baselineMessageId)) { + return; + } + masterAgentReplyWaiting = true; + masterAgentReplyTimedOut = false; + masterAgentReplyBaselineMessageId = baselineMessageId; + } + + private boolean isTrackingMasterAgentReply() { + return !TextUtils.isEmpty(masterAgentReplyBaselineMessageId) + && (isMasterAgentConversation() || "master".equals(pendingReplyPresenter)); + } + + private boolean shouldKeepPollingTrackedMasterReply() { + return masterAgentReplyTimedOut && isTrackingMasterAgentReply(); + } + private void clearMasterAgentReplyState() { masterAgentReplyWaiting = false; masterAgentReplyTimedOut = false; @@ -2318,7 +3252,10 @@ public class ProjectDetailActivity extends BossScreenActivity { if (chatScrollView == null) { return; } - chatScrollView.post(() -> chatScrollView.fullScroll(View.FOCUS_DOWN)); + chatScrollView.post(() -> { + chatScrollView.fullScroll(View.FOCUS_DOWN); + updateScrollBottomShortcutVisibility(); + }); } private void appendPendingOutgoingBubble(String body) { @@ -2375,6 +3312,9 @@ public class ProjectDetailActivity extends BossScreenActivity { return; } boolean composerBusy = isComposerBusy(); + if (composerBusy) { + hideMentionSuggestions(); + } if (composerAttachmentButton != null) { composerAttachmentButton.setEnabled(!composerBusy); } @@ -2396,7 +3336,48 @@ public class ProjectDetailActivity extends BossScreenActivity { return true; } int remainingScroll = child.getBottom() - (chatScrollView.getScrollY() + chatScrollView.getHeight()); - return remainingScroll <= BossUi.dp(this, 96); + return isChatNearBottom(remainingScroll, BossUi.dp(this, 96)); + } + + private boolean shouldShowScrollBottomShortcut(int currentScrollY, int previousScrollY) { + if (chatScrollView == null || chatScrollView.getChildCount() == 0 || chatScrollView.getHeight() == 0) { + return false; + } + View child = chatScrollView.getChildAt(0); + if (child == null || child.getHeight() == 0) { + return false; + } + int remainingScroll = child.getBottom() - (chatScrollView.getScrollY() + chatScrollView.getHeight()); + return shouldShowScrollBottomShortcut( + remainingScroll, + BossUi.dp(this, 96), + currentScrollY, + previousScrollY, + scrollBottomButton != null && scrollBottomButton.getVisibility() == View.VISIBLE + ); + } + + static boolean shouldShowScrollBottomShortcut( + int remainingScroll, + int thresholdPx, + int currentScrollY, + int previousScrollY, + boolean currentlyVisible + ) { + if (remainingScroll <= thresholdPx) { + return false; + } + if (currentScrollY > previousScrollY) { + return true; + } + if (currentScrollY < previousScrollY) { + return false; + } + return currentlyVisible; + } + + static boolean isChatNearBottom(int remainingScroll, int thresholdPx) { + return remainingScroll <= thresholdPx; } private boolean isOutgoingMessage(String senderLabel, @Nullable String sender) { @@ -2411,19 +3392,163 @@ public class ProjectDetailActivity extends BossScreenActivity { || senderLabel.equals(apiClient.getAccountLabel()); } + private boolean isMasterAgentMessage(@Nullable String senderLabel, @Nullable String sender) { + if ("master".equals(sender)) { + return true; + } + String normalized = senderLabel == null ? "" : senderLabel.trim().replace(" ", ""); + return normalized.startsWith("主Agent"); + } + + private void syncProjectDeviceAvatarContext(@Nullable JSONObject project, @Nullable JSONArray devices) { + currentDeviceAvatarById.clear(); + currentDeviceNameById.clear(); + currentProjectDeviceAvatarLabel = ""; + currentProjectDeviceName = ""; + if (devices == null) { + return; + } + for (int i = 0; i < devices.length(); i += 1) { + JSONObject device = devices.optJSONObject(i); + if (device == null) { + continue; + } + String deviceId = device.optString("id", "").trim(); + String deviceName = device.optString("name", deviceId).trim(); + String avatar = device.optString("avatar", "").trim(); + if (TextUtils.isEmpty(avatar)) { + avatar = deviceName; + } + if (!TextUtils.isEmpty(deviceId)) { + currentDeviceAvatarById.put(deviceId, avatar); + currentDeviceNameById.put(deviceId, deviceName); + } + if (TextUtils.isEmpty(currentProjectDeviceAvatarLabel)) { + currentProjectDeviceAvatarLabel = avatar; + currentProjectDeviceName = TextUtils.isEmpty(deviceName) ? deviceId : deviceName; + } + } + JSONArray projectDeviceIds = project == null ? null : project.optJSONArray("deviceIds"); + if (projectDeviceIds == null || projectDeviceIds.length() == 0) { + return; + } + String primaryDeviceId = projectDeviceIds.optString(0, "").trim(); + String primaryAvatar = currentDeviceAvatarById.get(primaryDeviceId); + if (!TextUtils.isEmpty(primaryAvatar)) { + currentProjectDeviceAvatarLabel = primaryAvatar; + currentProjectDeviceName = currentDeviceNameById.get(primaryDeviceId); + } + } + + @Nullable + private MessageSourceAvatar resolveMessageSourceAvatar(JSONObject message, String kind, boolean outgoing) { + if (message == null || outgoing || isMasterAgentConversation() || TextUtils.isEmpty(currentProjectDeviceAvatarLabel)) { + return null; + } + if ("execution_progress".equals(kind)) { + return null; + } + String explicitDeviceId = firstNonEmpty( + message.optString("deviceId", ""), + message.optString("sourceDeviceId", ""), + message.optString("completedByDeviceId", "") + ); + if (!TextUtils.isEmpty(explicitDeviceId)) { + String avatar = currentDeviceAvatarById.get(explicitDeviceId); + if (!TextUtils.isEmpty(avatar)) { + return new MessageSourceAvatar(avatar, firstNonEmpty(currentDeviceNameById.get(explicitDeviceId), explicitDeviceId)); + } + } + String senderLabel = message.optString("senderLabel", ""); + for (Map.Entry entry : currentDeviceNameById.entrySet()) { + String deviceName = entry.getValue(); + if (TextUtils.isEmpty(deviceName) || TextUtils.isEmpty(senderLabel) || !senderLabel.contains(deviceName)) { + continue; + } + String avatar = currentDeviceAvatarById.get(entry.getKey()); + if (!TextUtils.isEmpty(avatar)) { + return new MessageSourceAvatar(avatar, deviceName); + } + } + return new MessageSourceAvatar( + currentProjectDeviceAvatarLabel, + firstNonEmpty(currentProjectDeviceName, "Codex 电脑") + ); + } + + private static String firstNonEmpty(@Nullable String first, @Nullable String second) { + return !TextUtils.isEmpty(first) ? first : TextUtils.isEmpty(second) ? "" : second; + } + + private static String firstNonEmpty(@Nullable String first, @Nullable String second, @Nullable String third) { + String selected = firstNonEmpty(first, second); + return !TextUtils.isEmpty(selected) ? selected : firstNonEmpty(third, ""); + } + + private String displaySenderLabelForMessage(@Nullable String senderLabel, @Nullable String sender) { + if ("user".equals(sender)) { + return "你"; + } + if (isMasterAgentMessage(senderLabel, sender)) { + return "主Agent"; + } + if (TextUtils.isEmpty(senderLabel)) { + return "消息"; + } + return senderLabel.trim(); + } + + private static final class MessageSourceAvatar { + final String avatarLabel; + final String sourceName; + + MessageSourceAvatar(String avatarLabel, String sourceName) { + this.avatarLabel = avatarLabel; + this.sourceName = sourceName; + } + } + private String formatMessageTime(String sentAt) { if (TextUtils.isEmpty(sentAt)) { return ""; } - int timeSeparator = sentAt.indexOf('T'); - if (timeSeparator >= 0 && sentAt.length() >= timeSeparator + 6) { - return sentAt.substring(timeSeparator + 1, timeSeparator + 6); + String normalized = sentAt.trim(); + Date parsed = parseMessageTimestamp(normalized); + if (parsed != null) { + return new SimpleDateFormat("HH:mm", Locale.getDefault()).format(parsed); } - int blankIndex = sentAt.indexOf(' '); - if (blankIndex >= 0 && sentAt.length() >= blankIndex + 6) { - return sentAt.substring(blankIndex + 1, blankIndex + 6); + int timeSeparator = normalized.indexOf('T'); + if (timeSeparator >= 0 && normalized.length() >= timeSeparator + 6) { + return normalized.substring(timeSeparator + 1, timeSeparator + 6); } - return sentAt; + int blankIndex = normalized.indexOf(' '); + if (blankIndex >= 0 && normalized.length() >= blankIndex + 6) { + return normalized.substring(blankIndex + 1, blankIndex + 6); + } + return normalized; + } + + private @Nullable Date parseMessageTimestamp(String value) { + String normalized = value.endsWith("Z") + ? value.substring(0, value.length() - 1) + "+00:00" + : value; + String[] patterns = { + "yyyy-MM-dd'T'HH:mm:ss.SSSXXX", + "yyyy-MM-dd'T'HH:mm:ssXXX", + "yyyy-MM-dd'T'HH:mm:ss.SSS", + "yyyy-MM-dd'T'HH:mm:ss", + "yyyy-MM-dd HH:mm:ss" + }; + for (String pattern : patterns) { + try { + SimpleDateFormat parser = new SimpleDateFormat(pattern, Locale.ROOT); + parser.setLenient(false); + return parser.parse(normalized); + } catch (ParseException ignored) { + // Try the next known server timestamp format. + } + } + return null; } private String resolveForwardSingleSourceLabel(JSONObject message) { @@ -2714,6 +3839,9 @@ public class ProjectDetailActivity extends BossScreenActivity { startMasterAgentReplyWait(waitSpec, includeDispatchPlans, waitingMessage); return; } + if (shouldTrackMasterAgentReplyInCurrentConversation()) { + beginMasterAgentReplyTracking(waitSpec.baselineMessageId); + } composerSending = true; updateComposerSendButtonState(); showMessage(waitingMessage); @@ -2725,9 +3853,7 @@ public class ProjectDetailActivity extends BossScreenActivity { boolean includeDispatchPlans, String waitingMessage ) { - masterAgentReplyWaiting = true; - masterAgentReplyTimedOut = false; - masterAgentReplyBaselineMessageId = waitSpec.baselineMessageId; + beginMasterAgentReplyTracking(waitSpec.baselineMessageId); composerSending = false; updateComposerSendButtonState(); setRefreshing(false); @@ -2822,7 +3948,7 @@ public class ProjectDetailActivity extends BossScreenActivity { } runOnUiThreadIfActive(() -> { - if (isMasterAgentConversation()) { + if (isTrackingMasterAgentReply()) { masterAgentReplyWaiting = false; masterAgentReplyTimedOut = true; } @@ -2839,7 +3965,7 @@ public class ProjectDetailActivity extends BossScreenActivity { return; } runOnUiThreadIfActive(() -> { - if (isMasterAgentConversation()) { + if (isTrackingMasterAgentReply()) { masterAgentReplyWaiting = false; masterAgentReplyTimedOut = true; } @@ -2876,6 +4002,7 @@ public class ProjectDetailActivity extends BossScreenActivity { chromeState.showMultiSelectBar, chromeState.showRefresh, chromeState.showHeaderAction, + !composerBusy && chromeState.copyEnabled, !composerBusy && chromeState.forwardEnabled, !chromeState.multiSelecting, chromeState.backLabel, @@ -2975,6 +4102,8 @@ public class ProjectDetailActivity extends BossScreenActivity { return "图片"; case "video_intent": return "视频"; + case "control_summary": + return "控制结果"; case "forward_notice": return "转发"; default: diff --git a/android/app/src/main/java/com/hyzq/boss/SecurityActivity.java b/android/app/src/main/java/com/hyzq/boss/SecurityActivity.java index 7a5562b..0cdb75b 100644 --- a/android/app/src/main/java/com/hyzq/boss/SecurityActivity.java +++ b/android/app/src/main/java/com/hyzq/boss/SecurityActivity.java @@ -5,6 +5,7 @@ import android.os.Bundle; import androidx.annotation.Nullable; +import org.json.JSONArray; import org.json.JSONObject; public class SecurityActivity extends BossScreenActivity { @@ -22,7 +23,11 @@ public class SecurityActivity extends BossScreenActivity { try { BossApiClient.ApiResponse response = apiClient.getSession(); if (!response.ok()) throw new IllegalStateException(response.message()); - runOnUiThread(() -> renderSecurity(response.json.optJSONObject("session"))); + BossApiClient.ApiResponse sessionsResponse = apiClient.getAuthSessions(); + JSONArray sessions = sessionsResponse.ok() + ? sessionsResponse.json.optJSONArray("sessions") + : new JSONArray(); + runOnUiThread(() -> renderSecurity(response.json.optJSONObject("session"), sessions)); } catch (Exception error) { runOnUiThread(() -> { setRefreshing(false); @@ -32,7 +37,7 @@ public class SecurityActivity extends BossScreenActivity { }); } - private void renderSecurity(@Nullable JSONObject session) { + private void renderSecurity(@Nullable JSONObject session, @Nullable JSONArray sessions) { replaceContent(); appendContent(BossUi.buildWechatMenuRow( this, @@ -55,6 +60,33 @@ public class SecurityActivity extends BossScreenActivity { )); } + appendContent(BossUi.buildWechatMenuRow( + this, + "登录会话", + "当前可管理 " + (sessions == null ? 0 : sessions.length()) + " 个登录端", + "点击非当前会话可撤销;撤销当前会话会回到登录页。", + null, + null + )); + if (sessions != null) { + for (int index = 0; index < sessions.length(); index += 1) { + JSONObject item = sessions.optJSONObject(index); + if (item == null) { + continue; + } + appendContent(BossUi.buildWechatMenuRow( + this, + buildSessionTitle(item), + item.optString("account", "-") + + " · " + BossUi.formatRoleLabel(item.optString("role", "-")), + "最近 " + item.optString("lastSeenAt", "-") + + " · 到期 " + item.optString("expiresAt", "-"), + item.optBoolean("current", false) ? "当前" : null, + v -> confirmRevokeSession(item.optString("sessionId", ""), item.optBoolean("current", false)) + )); + } + } + appendContent(BossUi.buildMenuRow(this, "打开设备页", "查看已绑定设备与状态", null, v -> { Intent intent = new Intent(this, MainActivity.class); intent.putExtra(MainActivity.EXTRA_INITIAL_TAB, "devices"); @@ -68,6 +100,56 @@ public class SecurityActivity extends BossScreenActivity { setRefreshing(false); } + private String buildSessionTitle(JSONObject session) { + String method = "code".equals(session.optString("loginMethod", "password")) ? "验证码登录" : "账号密码登录"; + String name = session.optString("displayName", session.optString("account", "登录端")); + return name + " · " + method; + } + + private void confirmRevokeSession(String sessionId, boolean current) { + if (sessionId == null || sessionId.isEmpty()) { + showMessage("会话 ID 缺失,无法撤销。"); + return; + } + new androidx.appcompat.app.AlertDialog.Builder(this) + .setTitle(current ? "退出当前会话" : "撤销登录会话") + .setMessage(current ? "撤销当前会话后需要重新登录。" : "只撤销这一端的登录态,不影响其他会话。") + .setNegativeButton("取消", null) + .setPositiveButton(current ? "退出" : "撤销", (dialog, which) -> revokeSession(sessionId, current)) + .show(); + } + + private void revokeSession(String sessionId, boolean current) { + setRefreshing(true); + executor.execute(() -> { + try { + BossApiClient.ApiResponse response = apiClient.revokeAuthSession(sessionId); + if (!response.ok()) { + throw new IllegalStateException(response.message()); + } + if (current) { + apiClient.logout(); + } + runOnUiThread(() -> { + showMessage("会话已撤销"); + if (current) { + Intent intent = new Intent(this, MainActivity.class); + intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP | Intent.FLAG_ACTIVITY_NEW_TASK); + startActivity(intent); + finish(); + } else { + reload(); + } + }); + } catch (Exception error) { + runOnUiThread(() -> { + setRefreshing(false); + showMessage("撤销失败:" + error.getMessage()); + }); + } + }); + } + private void logout() { setRefreshing(true); executor.execute(() -> { diff --git a/android/app/src/main/java/com/hyzq/boss/StorageSettingsActivity.java b/android/app/src/main/java/com/hyzq/boss/StorageSettingsActivity.java new file mode 100644 index 0000000..2af44e6 --- /dev/null +++ b/android/app/src/main/java/com/hyzq/boss/StorageSettingsActivity.java @@ -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); + } + } +} diff --git a/android/app/src/main/java/com/hyzq/boss/TelegramIntegrationActivity.java b/android/app/src/main/java/com/hyzq/boss/TelegramIntegrationActivity.java new file mode 100644 index 0000000..4956d9e --- /dev/null +++ b/android/app/src/main/java/com/hyzq/boss/TelegramIntegrationActivity.java @@ -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); + } + } +} diff --git a/android/app/src/main/java/com/hyzq/boss/WechatSurfaceMapper.java b/android/app/src/main/java/com/hyzq/boss/WechatSurfaceMapper.java index daab70c..0ccbd82 100644 --- a/android/app/src/main/java/com/hyzq/boss/WechatSurfaceMapper.java +++ b/android/app/src/main/java/com/hyzq/boss/WechatSurfaceMapper.java @@ -12,6 +12,101 @@ import java.util.List; import java.util.Map; public final class WechatSurfaceMapper { + private static final String[] PROCESS_PREVIEW_PREFIXES = new String[] { + "我先", + "我现在", + "我会先", + "我发现", + "我准备", + "接下来", + "正在", + "先看", + "先读", + "我把", + "我再", + "目前在", + "现在在", + "补一组", + "处理一下", + "先确认", + "准备", + "同步一下", + "我这边已经" + }; + + private static final String[] PROCESS_PREVIEW_CONTAINS = new String[] { + "我继续", + "我已经在", + "正在跑", + "正在检查", + "正在处理", + "正在同步", + "我会直接", + "我先把", + "先补", + "再接" + }; + + private static final String[] PROCESS_PREVIEW_NUMBERED_HINTS = new String[] { + "先", + "再", + "接下来", + "然后", + "检查", + "确认", + "处理", + "同步", + "补", + "排查", + "推进", + "回你", + "回传", + "会把", + "我会" + }; + + private static final String[] PROCESS_PREVIEW_BLOCK_MARKERS = new String[] { + "失败", + "报错", + "错误", + "阻塞", + "不能", + "无法", + "崩溃", + "超时", + "exception", + "error", + "fatal", + "结论", + "最终", + "总结", + "已完成", + "已经完成", + "验证通过" + }; + + private static final String[] LEAKED_TITLE_PREFIXES = new String[] { + "你当前接手的项目根目录是", + "你现在接手的项目根目录是", + "你现在以目标线程身份直接回复用户", + "你正在向主 Agent 同步当前项目状态", + "只回复对用户真正有用的内容", + "只输出 JSON" + }; + + private static final String[] LEAKED_TITLE_CONTAINS = new String[] { + "不要发送内部字段", + "不要自称主 Agent", + "不要解释系统如何分发", + "不要输出 JSON", + "项目名称:", + "线程名称:", + "文件夹:", + "同步原因:", + "当前消息:", + "用户当前消息:" + }; + private static final List ROOT_TAB_LABELS = Arrays.asList( "会话", "设备", @@ -21,8 +116,11 @@ public final class WechatSurfaceMapper { private static final List ROOT_ME_MENU_ITEMS = Arrays.asList( new MeMenuItem("security", "账号与安全", "修改登录密码、设备安全与身份校验"), new MeMenuItem("settings", "设置", "默认首页、提醒方式与危险操作确认"), + new MeMenuItem("access", "用户与权限", "分配子账号、设备、项目与 Skill 权限"), new MeMenuItem("ops", "运维与修复", "查看运维会话、修复回放与 standby 切换"), new MeMenuItem("ai_accounts", "AI 账号", "管理主 GPT、备用 GPT 与 API 容灾"), + new MeMenuItem("storage", "附件与存储", "配置附件上传位置、服务器文件与阿里 OSS"), + new MeMenuItem("telegram", "Telegram 接入", "配置 Telegram Bot、Webhook 与白名单"), new MeMenuItem("skills", "技能", "按设备查看 Skill 清单"), new MeMenuItem("about", "关于", "当前版本、OTA 状态与更新内容") ); @@ -59,14 +157,20 @@ public final class WechatSurfaceMapper { JSONObject avatar = source.optJSONObject("avatar"); boolean isGroup = source.optBoolean("isGroup", groupAvatarMembers.size() > 1); String conversationType = source.optString("conversationType", ""); - String threadTitle = trimLocalWorkspacePrefix( - source.optString("threadTitle", source.optString("title", source.optString("projectTitle", ""))) + String folderLabel = normalizeConversationTitle(source.optString("folderLabel", "")); + String threadTitle = sanitizeConversationTitle( + source.optString("threadTitle", source.optString("title", source.optString("projectTitle", ""))), + folderLabel, + source.optString("projectTitle", "") ); + String projectId = source.optString("projectId", "").trim(); if ("folder_archive".equals(conversationType)) { threadTitle = source.optString( "projectTitle", source.optString("threadTitle", source.optString("title", source.optString("folderLabel", ""))) ); + } else if (isPinnedSystemProject(projectId)) { + threadTitle = source.optString("projectTitle", threadTitle); } String pinnedLabel = source.optString("topPinnedLabel", ""); return new ConversationRow( @@ -188,10 +292,36 @@ public final class WechatSurfaceMapper { return titles; } + public static String[] rootMeMenuTitlesForRole(String role) { + List items = rootMeMenuItemsForRoleList(role); + String[] titles = new String[items.size()]; + for (int i = 0; i < items.size(); i++) { + titles[i] = items.get(i).title; + } + return titles; + } + public static MeMenuItem[] rootMeMenuItems() { return ROOT_ME_MENU_ITEMS.toArray(new MeMenuItem[0]); } + public static MeMenuItem[] rootMeMenuItemsForRole(String role) { + List items = rootMeMenuItemsForRoleList(role); + return items.toArray(new MeMenuItem[0]); + } + + public static boolean canOpenMeEntryForRole(String key, String role) { + if (key == null || key.trim().isEmpty()) { + return false; + } + for (MeMenuItem item : rootMeMenuItemsForRoleList(role)) { + if (item.key.equals(key)) { + return true; + } + } + return false; + } + public static MeMenuItem findMeMenuItem(String key) { for (MeMenuItem item : ROOT_ME_MENU_ITEMS) { if (item.key.equals(key)) { @@ -201,6 +331,34 @@ public final class WechatSurfaceMapper { return null; } + private static List rootMeMenuItemsForRoleList(String role) { + if ("highest_admin".equals(role)) { + return ROOT_ME_MENU_ITEMS; + } + List visible = new ArrayList<>(); + for (MeMenuItem item : ROOT_ME_MENU_ITEMS) { + if (!isHighestAdminOnlyMeEntry(item.key) && (isAdministratorRole(role) || !isAdministratorOnlyMeEntry(item.key))) { + visible.add(item); + } + } + return visible; + } + + private static boolean isAdministratorRole(String role) { + return "highest_admin".equals(role) || "admin".equals(role); + } + + private static boolean isAdministratorOnlyMeEntry(String key) { + return "ops".equals(key) + || "ai_accounts".equals(key) + || "storage".equals(key) + || "telegram".equals(key); + } + + private static boolean isHighestAdminOnlyMeEntry(String key) { + return "access".equals(key); + } + public static String[] projectQuickActions() { return PROJECT_QUICK_ACTIONS.toArray(new String[0]); } @@ -249,6 +407,10 @@ public final class WechatSurfaceMapper { return "cancel_on_detach"; } + private static boolean isPinnedSystemProject(String projectId) { + return "master-agent".equals(projectId) || "audit-collab".equals(projectId); + } + private static String buildContextStatusLabel(JSONObject source) { if (source.optBoolean("mustFinishBeforeCompaction", false)) { return "必须收尾"; @@ -322,7 +484,14 @@ public final class WechatSurfaceMapper { } public static RootTopAction rootTopAction(String activeTab, boolean refreshing, boolean selectionMode) { + return rootTopAction(activeTab, refreshing, selectionMode, "highest_admin"); + } + + public static RootTopAction rootTopAction(String activeTab, boolean refreshing, boolean selectionMode, String role) { if ("devices".equals(activeTab)) { + if (!"highest_admin".equals(role)) { + return new RootTopAction("刷新", false, true, "refresh", "refresh"); + } return new RootTopAction("添加设备", false, true, "add", "add_device"); } if ("conversations".equals(activeTab)) { @@ -714,9 +883,156 @@ public final class WechatSurfaceMapper { if (preview.matches("^已从设备.+导入线程《.+》[。.]?$")) { return "已导入线程"; } + if (isLikelyProcessPreview(preview)) { + return ""; + } return preview; } + private static boolean isLikelyProcessPreview(String value) { + String preview = value == null ? "" : value + .replace("\r\n", "\n") + .replace('\r', '\n') + .replaceAll("\\n{2,}", "\n") + .trim(); + if (preview.isEmpty()) { + return false; + } + if (containsMarker(preview, PROCESS_PREVIEW_BLOCK_MARKERS)) { + return false; + } + if (isStructuredNumberedProcessPreview(preview)) { + return true; + } + String normalized = preview.toLowerCase(java.util.Locale.ROOT); + for (String marker : PROCESS_PREVIEW_PREFIXES) { + if (normalized.startsWith(marker.toLowerCase(java.util.Locale.ROOT))) { + return true; + } + } + for (String marker : PROCESS_PREVIEW_CONTAINS) { + if (normalized.contains(marker.toLowerCase(java.util.Locale.ROOT))) { + return true; + } + } + return false; + } + + private static boolean isStructuredNumberedProcessPreview(String value) { + String[] rawLines = value + .replace("\r\n", "\n") + .replace('\r', '\n') + .split("\n"); + ArrayList numberedLines = new ArrayList<>(); + for (String rawLine : rawLines) { + String normalizedLine = rawLine == null ? "" : rawLine.trim(); + if (normalizedLine.isEmpty()) { + continue; + } + if (normalizedLine.matches("^\\d+[.)、]\\s*.+$")) { + numberedLines.add(normalizedLine); + } + } + if (numberedLines.size() < 2) { + return false; + } + String merged = android.text.TextUtils.join(" ", numberedLines) + .toLowerCase(java.util.Locale.ROOT); + return containsMarker(merged, PROCESS_PREVIEW_NUMBERED_HINTS); + } + + private static boolean containsMarker(String value, String[] markers) { + String normalized = value == null ? "" : value.toLowerCase(java.util.Locale.ROOT); + for (String marker : markers) { + if (normalized.contains(marker.toLowerCase(java.util.Locale.ROOT))) { + return true; + } + } + return false; + } + + private static String normalizeConversationTitle(String value) { + String source = value == null ? "" : value.replace("\u0000", ""); + String[] lines = source.split("\\r?\\n"); + for (String line : lines) { + if (line == null) { + continue; + } + String trimmed = line.trim(); + if (!trimmed.isEmpty()) { + return trimmed.replaceAll("\\s+", " "); + } + } + return ""; + } + + private static String stripTrailingConversationTitleNoise(String value) { + return value == null ? "" : value.replaceAll("['\"}\\]]{2,}$", "").trim(); + } + + private static boolean looksLikeLeakedConversationTitle(String value) { + String normalized = normalizeConversationTitle(value); + if (normalized.isEmpty()) { + return false; + } + for (String marker : LEAKED_TITLE_PREFIXES) { + if (normalized.startsWith(marker)) { + return true; + } + } + for (String marker : LEAKED_TITLE_CONTAINS) { + if (normalized.contains(marker)) { + return true; + } + } + return false; + } + + private static String extractWorkspaceProjectName(String value) { + String normalized = normalizeConversationTitle(value).replace('\\', '/'); + if (normalized.isEmpty()) { + return ""; + } + String[] patterns = new String[] { + ".*/Users/[^/]+/code/([^/\\s\"'`,。;!?]+).*", + ".*/home/[^/]+/code/([^/\\s\"'`,。;!?]+).*", + ".*[A-Za-z]:/Users/[^/]+/code/([^/\\s\"'`,。;!?]+).*" + }; + for (String pattern : patterns) { + if (normalized.matches(pattern)) { + return normalized.replaceFirst(pattern, "$1").split("/")[0].trim(); + } + } + return ""; + } + + private static String sanitizeConversationTitle(String value, String... fallbackCandidates) { + String normalized = normalizeConversationTitle(value); + String trimmed = stripTrailingConversationTitleNoise(trimLocalWorkspacePrefix(normalized)); + if (!trimmed.isEmpty() && !looksLikeLeakedConversationTitle(normalized) && !looksLikeLeakedConversationTitle(trimmed)) { + return trimmed; + } + + String extractedProject = extractWorkspaceProjectName(normalized); + if (!extractedProject.isEmpty() && !looksLikeLeakedConversationTitle(extractedProject)) { + return extractedProject; + } + + for (String fallbackCandidate : fallbackCandidates) { + String extractedFallback = extractWorkspaceProjectName(fallbackCandidate); + if (!extractedFallback.isEmpty() && !looksLikeLeakedConversationTitle(extractedFallback)) { + return extractedFallback; + } + String normalizedFallback = stripTrailingConversationTitleNoise( + trimLocalWorkspacePrefix(normalizeConversationTitle(fallbackCandidate)) + ); + if (!normalizedFallback.isEmpty() && !looksLikeLeakedConversationTitle(normalizedFallback)) { + return normalizedFallback; + } + } + return trimmed; + } + private static String trimLocalWorkspacePrefix(String value) { String label = value == null ? "" : value.trim(); if (label.isEmpty()) { diff --git a/android/app/src/main/res/drawable/bg_chat_scroll_bottom_button.xml b/android/app/src/main/res/drawable/bg_chat_scroll_bottom_button.xml new file mode 100644 index 0000000..d5d2c26 --- /dev/null +++ b/android/app/src/main/res/drawable/bg_chat_scroll_bottom_button.xml @@ -0,0 +1,8 @@ + + + + + + diff --git a/android/app/src/main/res/drawable/ic_boss_arrow_down.xml b/android/app/src/main/res/drawable/ic_boss_arrow_down.xml new file mode 100644 index 0000000..d5da60e --- /dev/null +++ b/android/app/src/main/res/drawable/ic_boss_arrow_down.xml @@ -0,0 +1,10 @@ + + + + diff --git a/android/app/src/main/res/drawable/ic_boss_tab_chat.xml b/android/app/src/main/res/drawable/ic_boss_tab_chat.xml new file mode 100644 index 0000000..489fb5f --- /dev/null +++ b/android/app/src/main/res/drawable/ic_boss_tab_chat.xml @@ -0,0 +1,10 @@ + + + + diff --git a/android/app/src/main/res/drawable/ic_boss_tab_devices.xml b/android/app/src/main/res/drawable/ic_boss_tab_devices.xml new file mode 100644 index 0000000..66255d2 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_boss_tab_devices.xml @@ -0,0 +1,10 @@ + + + + diff --git a/android/app/src/main/res/drawable/ic_boss_tab_me.xml b/android/app/src/main/res/drawable/ic_boss_tab_me.xml new file mode 100644 index 0000000..3a2f2e9 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_boss_tab_me.xml @@ -0,0 +1,10 @@ + + + + diff --git a/android/app/src/main/res/layout/activity_main.xml b/android/app/src/main/res/layout/activity_main.xml index 6df5a87..aab098b 100644 --- a/android/app/src/main/res/layout/activity_main.xml +++ b/android/app/src/main/res/layout/activity_main.xml @@ -52,11 +52,96 @@ android:textColor="@color/boss_text_muted" android:textSize="14sp" /> + + + + + + + + + + + + ); + })} + + ); +} + +function grantLabel(grant: AccountDeviceGrant | AccountProjectGrant | AccountSkillGrant) { + if ("skillId" in grant) { + return `${grant.account} · Skill ${grant.skillId}`; + } + if ("projectId" in grant) { + return `${grant.account} · 项目 ${grant.projectId}`; + } + return `${grant.account} · 设备 ${grant.deviceId}`; +} + +function formatGrantExpiry(expiresAt?: string) { + if (!expiresAt) { + return { label: "永久有效", expired: false }; + } + const expiresAtMs = new Date(expiresAt).getTime(); + if (Number.isNaN(expiresAtMs)) { + return { label: `有效期:${expiresAt}`, expired: false }; + } + return { + label: `${expiresAtMs <= Date.now() ? "已过期" : "有效至"}:${new Date(expiresAtMs).toLocaleString("zh-CN", { + hour12: false, + })}`, + expired: expiresAtMs <= Date.now(), + }; +} + +function formatAuditTarget(log: AccessManagementView["auditLogs"][number]) { + return [ + log.targetAccount ? `账号 ${log.targetAccount}` : "", + log.deviceId ? `设备 ${log.deviceId}` : "", + log.projectId ? `项目 ${log.projectId}` : "", + log.skillId ? `Skill ${log.skillId}` : "", + ].filter(Boolean).join(" · ") || "系统"; +} + +export function AccessManagementClient({ initialView }: { initialView: AccessManagementView }) { + const router = useRouter(); + const [view, setView] = useState(initialView); + const [busy, setBusy] = useState(""); + const [message, setMessage] = useState(""); + const [accountDraft, setAccountDraft] = useState({ + account: "", + displayName: "", + role: "member" as AuthRole, + password: "", + }); + const [deviceDraft, setDeviceDraft] = useState({ + account: "", + deviceId: initialView.devices[0]?.id ?? "", + permissions: ["device.view"] as BossPermission[], + expiresAt: "", + }); + const [projectDraft, setProjectDraft] = useState({ + account: "", + projectId: initialView.projects[0]?.id ?? "", + permissions: ["project.view"] as BossPermission[], + expiresAt: "", + }); + const [skillDraft, setSkillDraft] = useState({ + account: "", + skillId: initialView.skills[0]?.skillId ?? "", + deviceId: initialView.skills[0]?.deviceId ?? "", + permissions: ["skill.view", "skill.use"] as BossPermission[], + expiresAt: "", + }); + const [templateDraft, setTemplateDraft] = useState({ + account: "", + templateId: initialView.permissionTemplates.find((item) => item.templateId === "developer")?.templateId ?? + initialView.permissionTemplates[0]?.templateId ?? "", + deviceId: initialView.devices[0]?.id ?? "", + projectId: initialView.projects[0]?.id ?? "", + skillId: initialView.skills[0]?.skillId ?? "", + }); + + const accountOptions = useMemo( + () => view.accounts.map((account) => ({ value: account.account, label: `${account.displayName} · ${account.account}` })), + [view.accounts], + ); + const deviceOptions = useMemo( + () => view.devices.map((device) => ({ value: device.id, label: `${device.name} · ${device.id}` })), + [view.devices], + ); + const projectOptions = useMemo( + () => view.projects.map((project) => ({ value: project.id, label: `${project.name} · ${project.id}` })), + [view.projects], + ); + const skillOptions = useMemo( + () => view.skills.map((skill) => ({ value: skill.skillId, label: `${skill.name} · ${skill.deviceId}` })), + [view.skills], + ); + const templateOptions = useMemo( + () => view.permissionTemplates.map((template) => ({ + value: template.templateId, + label: template.name, + })), + [view.permissionTemplates], + ); + const selectedTemplate = view.permissionTemplates.find((template) => template.templateId === templateDraft.templateId); + + async function refreshView() { + const response = await fetch("/api/v1/admin/access", { cache: "no-store" }); + const result = (await response.json()) as AccessManagementView & { ok: boolean; message?: string }; + if (!response.ok || !result.ok) { + throw new Error(result.message ?? "刷新失败"); + } + setView(result); + router.refresh(); + } + + async function submit(action: string, body: Record) { + setBusy(action); + setMessage(""); + try { + const response = await fetch("/api/v1/admin/access", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ action, ...body }), + }); + const result = (await response.json()) as { ok: boolean; message?: string }; + if (!response.ok || !result.ok) { + throw new Error(result.message ?? "保存失败"); + } + await refreshView(); + setMessage("已保存。"); + } catch (error) { + setMessage(error instanceof Error ? error.message : "操作失败"); + } finally { + setBusy(""); + } + } + + function fillAccountForGrants(account: string) { + setDeviceDraft((draft) => ({ ...draft, account })); + setProjectDraft((draft) => ({ ...draft, account })); + setSkillDraft((draft) => ({ ...draft, account })); + setTemplateDraft((draft) => ({ ...draft, account })); + } + + return ( +
+
+
+ { + setAccountDraft((draft) => ({ ...draft, account })); + fillAccountForGrants(account); + }} + placeholder="worker@example.com" + /> + setAccountDraft((draft) => ({ ...draft, displayName }))} + placeholder="项目协作者" + /> + setAccountDraft((draft) => ({ ...draft, role: role as AuthRole }))} + options={[ + { value: "member", label: "成员" }, + { value: "admin", label: "管理员" }, + ]} + /> + setAccountDraft((draft) => ({ ...draft, password }))} + placeholder="创建账号时必填" + secret + /> + +
+
+ + {view.permissionTemplates.length > 0 ? ( +
+ setTemplateDraft((draft) => ({ ...draft, account }))} + options={[{ value: "", label: "选择账号" }, ...accountOptions]} + /> + setTemplateDraft((draft) => ({ ...draft, templateId }))} + options={templateOptions} + /> + {selectedTemplate ? ( +
+ {selectedTemplate.description} +
+ ) : null} + setTemplateDraft((draft) => ({ ...draft, deviceId }))} + options={[{ value: "", label: "不授权设备" }, ...deviceOptions]} + /> + setTemplateDraft((draft) => ({ ...draft, projectId }))} + options={[{ value: "", label: "不授权项目" }, ...projectOptions]} + /> + setTemplateDraft((draft) => ({ ...draft, skillId }))} + options={[{ value: "", label: "不分配 Skill" }, ...skillOptions]} + /> + +
+ ) : null} + +
+ setDeviceDraft((draft) => ({ ...draft, account }))} + options={[{ value: "", label: "选择账号" }, ...accountOptions]} + /> + setDeviceDraft((draft) => ({ ...draft, deviceId }))} + options={deviceOptions} + /> + setDeviceDraft((draft) => ({ ...draft, permissions }))} + /> + setDeviceDraft((draft) => ({ ...draft, expiresAt }))} + placeholder="留空表示永久,例如 2026-05-01T18:00:00+08:00" + /> + +
+ +
+ setProjectDraft((draft) => ({ ...draft, account }))} + options={[{ value: "", label: "选择账号" }, ...accountOptions]} + /> + setProjectDraft((draft) => ({ ...draft, projectId }))} + options={projectOptions} + /> + setProjectDraft((draft) => ({ ...draft, permissions }))} + /> + setProjectDraft((draft) => ({ ...draft, expiresAt }))} + placeholder="留空表示永久,例如 2026-05-01T18:00:00+08:00" + /> + +
+ +
+
+ 当前 Skill 目录:{view.skillCatalog.length} 类,覆盖{" "} + {view.skillCatalog.reduce((sum, item) => sum + item.deviceCount, 0)} 个设备实例。 +
+ setSkillDraft((draft) => ({ ...draft, account }))} + options={[{ value: "", label: "选择账号" }, ...accountOptions]} + /> + { + const skill = view.skills.find((item) => item.skillId === skillId); + setSkillDraft((draft) => ({ ...draft, skillId, deviceId: skill?.deviceId ?? draft.deviceId })); + }} + options={skillOptions} + /> + setSkillDraft((draft) => ({ ...draft, permissions }))} + /> + setSkillDraft((draft) => ({ ...draft, expiresAt }))} + placeholder="留空表示永久,例如 2026-05-01T18:00:00+08:00" + /> + +
+ +
+ {[...view.grants.devices, ...view.grants.projects, ...view.grants.skills].length === 0 ? ( +
暂无授权。
+ ) : ( + [...view.grants.devices, ...view.grants.projects, ...view.grants.skills].map((grant) => { + const expiry = formatGrantExpiry(grant.expiresAt); + return ( +
+
+
+
{grantLabel(grant)}
+
+ {grant.permissions.join(" / ")} +
+
+ + {expiry.label} + +
+ {grant.note ? ( +
备注:{grant.note}
+ ) : null} + +
+ ); + }) + )} +
+ +
+ {view.auditLogs.length === 0 ? ( +
暂无审计记录。
+ ) : ( + view.auditLogs.slice(0, 20).map((log) => ( +
+
+
+
+ {log.action} · {formatAuditTarget(log)} +
+
+ 操作人:{log.actorAccount} + {log.permissions?.length ? ` · ${log.permissions.join(" / ")}` : ""} +
+
+
+ {new Date(log.createdAt).toLocaleString("zh-CN", { hour12: false })} +
+
+
+ )) + )} +
+ + {message ? ( +
+ {message} +
+ ) : null} +
+ ); +} diff --git a/src/components/admin/admin-access-panel.tsx b/src/components/admin/admin-access-panel.tsx new file mode 100644 index 0000000..3da89d8 --- /dev/null +++ b/src/components/admin/admin-access-panel.tsx @@ -0,0 +1,1048 @@ +"use client"; + +import { useEffect, useMemo, useState } from "react"; +import { + Alert, + Button, + Card, + Checkbox, + Form, + Input, + Select, + Space, + Table, + Tag, + Typography, + message, +} from "antd"; +import type { ColumnsType } from "antd/es/table"; +import type { + AccountDeviceGrant, + AccountProjectGrant, + AccountSkillGrant, + BossPermission, +} from "@/lib/boss-data"; + +type AdminAccountRole = "member" | "admin"; + +type PublicAdminAccount = { + account: string; + displayName?: string; + role?: string; + status?: "active" | "disabled" | string; + companyId?: string; + mfaRequired?: boolean; + createdAt?: string; + updatedAt?: string; + lastLoginAt?: string; +}; + +type AdminDevice = { + id: string; + name?: string; + status?: string; + companyId?: string; +}; + +type AdminCompany = { + companyId: string; + name?: string; + ownerAccount?: string; + successOwnerAccount?: string; + planTier?: "trial" | "standard" | "enterprise" | string; + contractExpiresAt?: string; + status?: "active" | "disabled" | string; + note?: string; + createdAt?: string; + updatedAt?: string; +}; + +type AdminProject = { + id: string; + name?: string; + deviceIds?: string[]; +}; + +type AdminSkill = { + skillId: string; + deviceId?: string; + name?: string; + invocation?: string; + description?: string; +}; + +type AdminSkillCatalogItem = { + name: string; + invocation?: string; + description?: string; + deviceCount: number; + devices: Array<{ + skillId: string; + deviceId: string; + path?: string; + category?: string; + updatedAt?: string; + }>; +}; + +type PermissionTemplate = { + templateId: string; + name: string; + description?: string; + devicePermissions: BossPermission[]; + projectPermissions: BossPermission[]; + skillPermissions: BossPermission[]; +}; + +type AuditLog = { + auditId: string; + actorAccount: string; + action: string; + targetAccount?: string; + deviceId?: string; + projectId?: string; + skillId?: string; + permissions?: BossPermission[]; + detail?: string; + createdAt: string; +}; + +export type AdminAccessView = { + accounts: PublicAdminAccount[]; + companies: AdminCompany[]; + devices: AdminDevice[]; + projects: AdminProject[]; + skills: AdminSkill[]; + skillCatalog: AdminSkillCatalogItem[]; + permissionTemplates: PermissionTemplate[]; + grants: { + devices: AccountDeviceGrant[]; + projects: AccountProjectGrant[]; + skills: AccountSkillGrant[]; + }; + auditLogs: AuditLog[]; +}; + +type AdminAccessPanelProps = { + initialView?: Partial | null; + className?: string; +}; + +type AccountFormValues = { + account: string; + displayName?: string; + role: AdminAccountRole; + companyId?: string; + password?: string; + verificationEmail?: string; +}; + +type CompanyFormValues = { + companyId: string; + name?: string; + ownerAccount?: string; + successOwnerAccount?: string; + planTier?: "trial" | "standard" | "enterprise"; + contractExpiresAt?: string; + note?: string; +}; + +type CompanyAssignFormValues = { + account?: string; + deviceId?: string; + companyId?: string; +}; + +type BulkImportFormValues = { + companyId?: string; + accountsText?: string; +}; + +type BulkImportAccountInput = { + account: string; + displayName?: string; + role: AdminAccountRole; + password?: string; +}; + +type BulkImportPreview = { + summary?: { + create?: number; + update?: number; + invalid?: number; + }; + rows?: Array<{ + account: string; + displayName?: string; + role?: string; + operation?: string; + valid?: boolean; + reason?: string; + }>; +}; + +type ReclaimFormValues = { + account?: string; + reason?: string; +}; + +type GrantFormValues = { + account?: string; + deviceId?: string; + projectId?: string; + skillId?: string; + permissions?: BossPermission[]; + expiresAt?: string; + note?: string; +}; + +type TemplateFormValues = { + account?: string; + templateId?: string; + deviceIds?: string[]; + projectIds?: string[]; + skillIds?: string[]; +}; + +const accessEndpoint = "/api/v1/admin/access"; + +const emptyView: AdminAccessView = { + accounts: [], + companies: [], + devices: [], + projects: [], + skills: [], + skillCatalog: [], + permissionTemplates: [], + grants: { + devices: [], + projects: [], + skills: [], + }, + auditLogs: [], +}; + +const devicePermissions: BossPermission[] = ["device.view", "device.manage", "computer.control"]; +const projectPermissions: BossPermission[] = [ + "project.view", + "thread.chat", + "master_agent.ask", + "master_agent.takeover", + "computer.control", +]; +const skillPermissions: BossPermission[] = ["skill.view", "skill.use", "skill.manage"]; +const adminDense = "adminDense space-y-3"; +const adminCard = "boss-admin-card border-[#E9ECE9] shadow-[0_10px_36px_rgba(0,0,0,0.035)]"; + +function mergeView(view?: Partial | null): AdminAccessView { + return { + ...emptyView, + ...(view ?? {}), + grants: { + ...emptyView.grants, + ...(view?.grants ?? {}), + }, + }; +} + +function text(value: unknown, fallback = "-") { + if (value === null || value === undefined || value === "") return fallback; + return String(value); +} + +function compact(values: Record) { + return Object.fromEntries( + Object.entries(values).filter(([, value]) => { + if (Array.isArray(value)) return value.length > 0; + return value !== undefined && value !== null && value !== ""; + }), + ); +} + +function grantLabel(grant: AccountDeviceGrant | AccountProjectGrant | AccountSkillGrant) { + if ("skillId" in grant) return `${grant.account} · Skill ${grant.skillId}`; + if ("projectId" in grant) return `${grant.account} · 项目 ${grant.projectId}`; + return `${grant.account} · 设备 ${grant.deviceId}`; +} + +function targetLabel(log: AuditLog) { + return [ + log.targetAccount ? `账号 ${log.targetAccount}` : "", + log.deviceId ? `设备 ${log.deviceId}` : "", + log.projectId ? `项目 ${log.projectId}` : "", + log.skillId ? `Skill ${log.skillId}` : "", + ].filter(Boolean).join(" · ") || "系统"; +} + +function accountStatusTag(status?: string) { + return status === "disabled" ? 已停用 : 正常; +} + +function parseBulkAccountsText(textValue?: string): BulkImportAccountInput[] { + return (textValue ?? "") + .split(/\r?\n/) + .map((line) => line.trim()) + .filter(Boolean) + .map((line) => { + const [account = "", displayName = "", role = "member", password = ""] = line.split(",").map((part) => part.trim()); + const normalizedRole: AdminAccountRole = role === "admin" ? "admin" : "member"; + return { + account, + displayName: displayName || undefined, + role: normalizedRole, + password: password || undefined, + }; + }) + .filter((item) => item.account.length > 0); +} + +function parseBulkAccountsCsv(csvValue?: string): BulkImportAccountInput[] { + const lines = (csvValue ?? "") + .split(/\r?\n/) + .map((line) => line.trim()) + .filter(Boolean); + const contentLines = lines[0]?.toLowerCase().startsWith("account,") ? lines.slice(1) : lines; + return parseBulkAccountsText(contentLines.join("\n")); +} + +function confirmDangerousAction(action: string) { + return window.confirm(`确认执行高风险操作:${action}?该操作会写入审计记录。`); +} + +async function readError(response: Response) { + const payload = (await response.json().catch(() => null)) as { message?: string } | null; + return payload?.message || `HTTP ${response.status}`; +} + +export function AdminAccessPanel({ initialView = null, className }: AdminAccessPanelProps) { + const [accountForm] = Form.useForm(); + const [companyForm] = Form.useForm(); + const [ownerForm] = Form.useForm(); + const [accountCompanyForm] = Form.useForm(); + const [deviceCompanyForm] = Form.useForm(); + const [bulkImportForm] = Form.useForm(); + const [reclaimForm] = Form.useForm(); + const [templateForm] = Form.useForm(); + const [deviceForm] = Form.useForm(); + const [projectForm] = Form.useForm(); + const [skillForm] = Form.useForm(); + const [messageApi, messageContext] = message.useMessage(); + const [view, setView] = useState(() => mergeView(initialView)); + const [bulkImportPreview, setBulkImportPreview] = useState(null); + const [loading, setLoading] = useState(false); + const [busy, setBusy] = useState(""); + const [error, setError] = useState(""); + + const accountOptions = useMemo( + () => view.accounts.map((account) => ({ + value: account.account, + label: `${text(account.displayName, account.account)} · ${account.account}`, + })), + [view.accounts], + ); + const companyNameById = useMemo( + () => new Map(view.companies.map((company) => [company.companyId, text(company.name, company.companyId)])), + [view.companies], + ); + const companyOptions = useMemo( + () => view.companies.map((company) => ({ + value: company.companyId, + label: `${text(company.name, company.companyId)} · ${company.companyId}`, + })), + [view.companies], + ); + const deviceOptions = useMemo( + () => view.devices.map((device) => ({ + value: device.id, + label: `${text(device.name, device.id)} · ${device.id} · ${text(device.status, "unknown")}`, + })), + [view.devices], + ); + const projectOptions = useMemo( + () => view.projects.map((project) => ({ value: project.id, label: `${text(project.name, project.id)} · ${project.id}` })), + [view.projects], + ); + const skillOptions = useMemo( + () => view.skills.map((skill) => ({ + value: skill.skillId, + label: `${text(skill.name ?? skill.invocation, skill.skillId)} · ${skill.deviceId ?? "unknown"}`, + })), + [view.skills], + ); + const templateOptions = useMemo( + () => view.permissionTemplates.map((template) => ({ value: template.templateId, label: template.name })), + [view.permissionTemplates], + ); + const accessRiskCount = view.devices.filter((device) => text(device.status, "unknown") !== "online").length; + + async function refreshView() { + setLoading(true); + setError(""); + try { + const response = await fetch(accessEndpoint, { + credentials: "include", + cache: "no-store", + }); + if (!response.ok) throw new Error(await readError(response)); + const payload = (await response.json()) as Partial & { ok?: boolean; message?: string }; + if (payload.ok === false) throw new Error(payload.message || "授权数据读取失败"); + setView(mergeView(payload)); + } catch (nextError) { + const messageText = nextError instanceof Error ? nextError.message : "授权数据读取失败"; + setError(messageText); + messageApi.error(messageText); + } finally { + setLoading(false); + } + } + + useEffect(() => { + if (initialView) return; + void refreshView(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + async function postAccess(action: string, body: Record) { + setBusy(action); + setError(""); + try { + const response = await fetch(accessEndpoint, { + method: "POST", + credentials: "include", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ action, ...compact(body) }), + }); + if (!response.ok) throw new Error(await readError(response)); + const payload = (await response.json()) as { ok?: boolean; message?: string }; + if (payload.ok === false) throw new Error(payload.message || "操作失败"); + messageApi.success("已保存"); + await refreshView(); + } catch (nextError) { + const messageText = nextError instanceof Error ? nextError.message : "操作失败"; + setError(messageText); + messageApi.error(messageText); + } finally { + setBusy(""); + } + } + + async function previewBulkImport(values: BulkImportFormValues) { + const accounts = parseBulkAccountsText(values.accountsText); + if (accounts.length === 0) { + messageApi.warning("请至少填写一个账号"); + return; + } + setBusy("preview_bulk_import_accounts"); + setError(""); + try { + const response = await fetch(accessEndpoint, { + method: "POST", + credentials: "include", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + action: "preview_bulk_import_accounts", + ...compact({ companyId: values.companyId, accounts }), + }), + }); + if (!response.ok) throw new Error(await readError(response)); + const payload = (await response.json()) as { ok?: boolean; message?: string; preview?: BulkImportPreview }; + if (payload.ok === false) throw new Error(payload.message || "预览失败"); + setBulkImportPreview(payload.preview ?? null); + messageApi.success("预览已生成"); + } catch (nextError) { + const messageText = nextError instanceof Error ? nextError.message : "预览失败"; + setError(messageText); + messageApi.error(messageText); + } finally { + setBusy(""); + } + } + + function promptResetPassword(account: PublicAdminAccount) { + if (!confirmDangerousAction(`reset_account_password:${account.account}`)) return; + const password = window.prompt(`为 ${account.account} 设置新密码`); + if (!password?.trim()) return; + void postAccess("reset_account_password", { account: account.account, password: password.trim() }); + } + + async function handleCsvFile(file?: File) { + if (!file) return; + const csv = await file.text(); + const accounts = parseBulkAccountsCsv(csv); + bulkImportForm.setFieldsValue({ + accountsText: accounts + .map((account) => [ + account.account, + account.displayName, + account.role, + account.password, + ].map((value) => text(value, "")).join(",")) + .join("\n"), + }); + messageApi.success(`CSV 已解析 ${accounts.length} 个账号`); + } + + const grantRows = [...view.grants.devices, ...view.grants.projects, ...view.grants.skills]; + const grantColumns: ColumnsType = [ + { title: "授权对象", render: (_, grant) => grantLabel(grant) }, + { title: "权限", render: (_, grant) => grant.permissions.join(" / ") }, + { title: "有效期", render: (_, grant) => text(grant.expiresAt, "永久有效") }, + { + title: "操作", + width: 110, + render: (_, grant) => ( + + ), + }, + ]; + + const auditColumns: ColumnsType = [ + { title: "动作", dataIndex: "action", width: 180 }, + { title: "目标", render: (_, log) => targetLabel(log) }, + { title: "操作人", dataIndex: "actorAccount", width: 180 }, + { title: "时间", dataIndex: "createdAt", width: 210 }, + ]; + + const companyColumns: ColumnsType = [ + { + title: "公司", + render: (_, company) => ( + + {text(company.name, company.companyId)} + {company.companyId} + + ), + }, + { title: "负责人", width: 180, render: (_, company) => text(company.ownerAccount, "未设置") }, + { title: "客户成功", width: 180, render: (_, company) => text(company.successOwnerAccount, "未设置") }, + { title: "套餐等级", width: 130, render: (_, company) => text(company.planTier, "未设置") }, + { title: "合同到期", width: 180, render: (_, company) => text(company.contractExpiresAt, "未设置") }, + { title: "状态", width: 110, render: (_, company) => accountStatusTag(company.status) }, + { title: "更新时间", width: 210, render: (_, company) => text(company.updatedAt) }, + { + title: "操作", + width: 120, + render: (_, company) => + company.status === "disabled" ? ( + + ) : ( + + ), + }, + ]; + + const accountColumns: ColumnsType = [ + { + title: "账号", + render: (_, account) => ( + + {text(account.displayName, account.account)} + {account.account} + + ), + }, + { title: "角色", dataIndex: "role", width: 140 }, + { title: "MFA", width: 120, render: (_, account) => (account.mfaRequired ? 已开启 : 未开启) }, + { + title: "所属公司", + width: 190, + render: (_, account) => text(account.companyId ? companyNameById.get(account.companyId) ?? account.companyId : undefined), + }, + { title: "状态", width: 120, render: (_, account) => accountStatusTag(account.status) }, + { title: "最近登录", width: 210, render: (_, account) => text(account.lastLoginAt, "暂无") }, + { title: "更新时间", width: 210, render: (_, account) => text(account.updatedAt) }, + { + title: "操作", + width: 300, + render: (_, account) => ( + + {account.status === "disabled" ? ( + + ) : ( + + )} + + + + + ), + }, + ]; + + return ( +
+ {messageContext} +
+ {error ? : null} + +
+ void refreshView()} loading={loading}>刷新}> + + form={accountForm} + layout="vertical" + initialValues={{ role: "member" }} + onFinish={(values) => void postAccess("upsert_account", values)} + > + + + + + + + + + + + + + + + + + + + + + 0 ? "error" : "success"} + showIcon + message="关键风险" + description={ + accessRiskCount > 0 + ? `${accessRiskCount} 台设备仍未在线或状态未知,需要确认授权范围。` + : "设备、账号和授权范围当前无明显风险。" + } + /> +
+
{view.accounts.length}账号
+
{grantRows.length}授权
+
{view.skillCatalog.length}Skill
+
+ + form={templateForm} + layout="vertical" + onFinish={(values) => void postAccess("apply_template", values)} + > + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + form={bulkImportForm} + layout="vertical" + onFinish={(values) => { + const accounts = parseBulkAccountsText(values.accountsText); + if (accounts.length === 0) { + messageApi.warning("请至少填写一个账号"); + return; + } + void postAccess("bulk_import_accounts", { companyId: values.companyId, accounts }); + }} + > + + void handleCsvFile(event.currentTarget.files?.[0])} + /> +
+ + + + + + {bulkImportPreview ? ( +
+ + 预览:新增 {bulkImportPreview.summary?.create ?? 0} 个,更新 {bulkImportPreview.summary?.update ?? 0} 个,异常 {bulkImportPreview.summary?.invalid ?? 0} 个 + + row.account} + pagination={false} + dataSource={bulkImportPreview.rows ?? []} + columns={[ + { title: "账号", dataIndex: "account" }, + { title: "角色", dataIndex: "role", width: 100 }, + { title: "动作", dataIndex: "operation", width: 100 }, + { title: "状态", width: 100, render: (_, row) => (row.valid ? 可导入 : {text(row.reason)}) }, + ]} + /> + + ) : null} + + + +
+ + + form={accountCompanyForm} + layout="vertical" + onFinish={(values) => void postAccess("assign_account_company", values)} + > + + + + + + + + + + form={deviceCompanyForm} + layout="vertical" + onFinish={(values) => void postAccess("assign_device_company", values)} + > + + + + + + + + + + form={reclaimForm} + layout="vertical" + onFinish={(values) => { + if (!confirmDangerousAction(`reclaim_account:${values.account}`)) return; + void postAccess("reclaim_account", values); + }} + > + + + + + + +
+ + {view.companies.length} 家公司}> +
+ + + {view.accounts.length} 个账号}> +
+ + +
设备绑定 / 范围授权 / SLA 授权
+
+ + form={deviceForm} layout="vertical" onFinish={(values) => void postAccess("grant_device", values)}> + + + + + ({ label: permission, value: permission }))} /> + + + + + + + + + + + + + form={projectForm} layout="vertical" onFinish={(values) => void postAccess("grant_project", values)}> + + + + + ({ label: permission, value: permission }))} /> + + + + + + + + + + + + + form={skillForm} layout="vertical" onFinish={(values) => void postAccess("grant_skill", values)}> + + + + + ({ label: permission, value: permission }))} /> + + + + + + + + + + +
+ + {grantRows.length} 条}> +
+ + + +
+ + + + + {view.skillCatalog.length === 0 ? ( + 暂无 Skill 目录。 + ) : ( + view.skillCatalog.map((item) => ( +
+
+
{item.name}
+
{text(item.description ?? item.invocation)}
+
+ {item.deviceCount} 台设备 +
+ )) + )} +
+
+ + + ); +} diff --git a/src/components/admin/admin-skill-lifecycle-panel.tsx b/src/components/admin/admin-skill-lifecycle-panel.tsx new file mode 100644 index 0000000..d2e81ba --- /dev/null +++ b/src/components/admin/admin-skill-lifecycle-panel.tsx @@ -0,0 +1,900 @@ +"use client"; + +import { useEffect, useMemo, useState } from "react"; +import { + Alert, + AutoComplete, + Button, + Card, + Divider, + Empty, + Form, + Input, + Select, + Space, + Table, + Tag, + Typography, + message, +} from "antd"; +import type { ColumnsType } from "antd/es/table"; + +const skillLifecycleRequestsEndpoint = "/api/v1/admin/skills/requests"; +const adminAccessEndpoint = "/api/v1/admin/access"; + +export type AdminSkillLifecycleAction = "install" | "update" | "uninstall" | "rollback" | "version_lock"; + +export type AdminSkillLifecycleDevice = { + id?: string; + deviceId?: string; + name?: string; + deviceName?: string; + status?: string; + onlineStatus?: string; + [key: string]: unknown; +}; + +export type AdminSkillLifecycleSkill = { + skillId: string; + deviceId?: string; + name?: string; + invocation?: string; + description?: string; + version?: string; + category?: string; + path?: string; + [key: string]: unknown; +}; + +export type AdminSkillLifecycleRequest = { + requestId?: string; + id?: string; + action?: AdminSkillLifecycleAction | string; + status?: string; + deviceId?: string; + skillId?: string; + sourceUrl?: string; + trustedSource?: string; + trustedSourceId?: string; + checksum?: string; + expectedChecksum?: string; + targetVersion?: string; + rollbackToVersion?: string; + lockedVersion?: string; + requestedBy?: string; + requestedAt?: string; + claimedByDeviceId?: string; + claimedAt?: string; + completedAt?: string; + updatedAt?: string; + note?: string; + resultSummary?: string; + error?: string; + [key: string]: unknown; +}; + +export type AdminSkillLifecycleTrustedSource = { + id?: string; + trustedSourceId?: string; + name?: string; + label?: string; + sourceUrl?: string; + [key: string]: unknown; +}; + +export type AdminSkillLifecycleCatalogDevice = { + skillId: string; + deviceId: string; + path?: string; + category?: string; + updatedAt?: string; +}; + +export type AdminSkillLifecycleCatalogItem = { + name: string; + invocation?: string; + description?: string; + deviceCount?: number; + devices?: AdminSkillLifecycleCatalogDevice[]; +}; + +export type AdminSkillLifecyclePanelProps = { + devices?: AdminSkillLifecycleDevice[]; + skills?: AdminSkillLifecycleSkill[]; + skillCatalog?: AdminSkillLifecycleCatalogItem[]; + initialRequests?: AdminSkillLifecycleRequest[]; + initialLifecycleRequests?: AdminSkillLifecycleRequest[]; + trustedSources?: AdminSkillLifecycleTrustedSource[]; + className?: string; +}; + +type LifecycleFormValues = { + action: AdminSkillLifecycleAction; + deviceId?: string; + skillId?: string; + sourceUrl?: string; + trustedSource?: string; + trustedSourceId?: string; + checksum?: string; + expectedChecksum?: string; + targetVersion?: string; + rollbackToVersion?: string; + lockedVersion?: string; + note?: string; +}; + +const lifecycleActions = [ + { value: "install", label: "安装", color: "green", description: "从 sourceUrl 或 trustedSourceId 安装新 Skill。" }, + { value: "update", label: "更新", color: "blue", description: "更新设备上已有 Skill,可指定版本或远程来源。" }, + { value: "uninstall", label: "卸载", color: "red", description: "卸载设备上已有 Skill,设备端会先做备份。" }, + { value: "rollback", label: "回滚", color: "orange", description: "回滚到指定历史版本。" }, + { value: "version_lock", label: "版本锁定", color: "purple", description: "锁定 Skill 到指定版本,写入设备端版本锁。" }, +] satisfies Array<{ + value: AdminSkillLifecycleAction; + label: string; + color: string; + description: string; +}>; + +const statusColors: Record = { + pending: "gold", + accepted: "cyan", + running: "blue", + completed: "green", + failed: "red", + canceled: "default", +}; +const adminDense = "adminDense"; +const adminCard = "boss-admin-card border-[#E9ECE9] shadow-[0_10px_36px_rgba(0,0,0,0.035)]"; +const panelSubtitle = "Skill 生命周期请求与执行结果"; + +function text(value: unknown, fallback = "-") { + if (value === null || value === undefined || value === "") return fallback; + return String(value); +} + +function trimmed(value: unknown) { + return typeof value === "string" ? value.trim() : ""; +} + +function deviceIdOf(device: AdminSkillLifecycleDevice) { + return text(device.id ?? device.deviceId, ""); +} + +function deviceLabel(device: AdminSkillLifecycleDevice) { + const id = deviceIdOf(device); + const name = text(device.name ?? device.deviceName ?? id, id || "未命名设备"); + const status = text(device.status ?? device.onlineStatus, ""); + return status ? `${name} · ${id} · ${status}` : `${name} · ${id}`; +} + +function skillLabel(skill: AdminSkillLifecycleSkill) { + const version = text(skill.version, ""); + const title = text(skill.name ?? skill.invocation ?? skill.skillId, skill.skillId); + return version ? `${title} · ${skill.skillId} · ${version}` : `${title} · ${skill.skillId}`; +} + +function skillName(skill: AdminSkillLifecycleSkill) { + return text(skill.name ?? skill.invocation ?? skill.skillId, skill.skillId); +} + +function deriveCatalog(skills: AdminSkillLifecycleSkill[]): AdminSkillLifecycleCatalogItem[] { + const catalog = new Map(); + + for (const skill of skills) { + const name = skillName(skill); + const existing = catalog.get(name) ?? { + name, + invocation: text(skill.invocation, ""), + description: text(skill.description, ""), + deviceCount: 0, + devices: [], + }; + existing.devices = [ + ...(existing.devices ?? []), + { + skillId: skill.skillId, + deviceId: text(skill.deviceId, ""), + path: text(skill.path, ""), + category: text(skill.category, ""), + updatedAt: text(skill.updatedAt, ""), + }, + ].filter((item) => item.skillId && item.deviceId); + existing.deviceCount = existing.devices.length; + catalog.set(name, existing); + } + + return [...catalog.values()].sort((left, right) => left.name.localeCompare(right.name, "zh-CN")); +} + +function requestTime(request: AdminSkillLifecycleRequest) { + return text(request.updatedAt ?? request.completedAt ?? request.claimedAt ?? request.requestedAt); +} + +function actionMeta(action: unknown) { + return lifecycleActions.find((item) => item.value === action) ?? lifecycleActions[0]; +} + +function compact(values: LifecycleFormValues) { + const payload: Record = { + action: values.action, + }; + + for (const key of [ + "deviceId", + "skillId", + "sourceUrl", + "trustedSource", + "trustedSourceId", + "checksum", + "expectedChecksum", + "targetVersion", + "rollbackToVersion", + "lockedVersion", + "note", + ] satisfies Array) { + const value = trimmed(values[key]); + if (value) payload[key] = value; + } + + return payload; +} + +async function readError(response: Response) { + const payload = (await response.json().catch(() => null)) as { message?: string } | null; + return payload?.message || `HTTP ${response.status}`; +} + +export function AdminSkillLifecyclePanel({ + devices = [], + skills = [], + skillCatalog = [], + initialRequests, + initialLifecycleRequests, + trustedSources = [], + className, +}: AdminSkillLifecyclePanelProps) { + const [form] = Form.useForm(); + const [messageApi, messageContext] = message.useMessage(); + const [requests, setRequests] = useState( + () => initialRequests ?? initialLifecycleRequests ?? [], + ); + const [availableDevices, setAvailableDevices] = useState(devices); + const [availableSkills, setAvailableSkills] = useState(skills); + const [availableSkillCatalog, setAvailableSkillCatalog] = + useState(skillCatalog); + const [action, setAction] = useState("install"); + const [selectedDeviceId, setSelectedDeviceId] = useState(""); + const [selectedSkillName, setSelectedSkillName] = useState(""); + const [loadingRequests, setLoadingRequests] = useState(false); + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(""); + + const deviceOptions = useMemo( + () => + availableDevices + .map((device) => ({ value: deviceIdOf(device), label: deviceLabel(device) })) + .filter((option) => option.value), + [availableDevices], + ); + + const skillOptions = useMemo(() => { + const available = selectedDeviceId + ? availableSkills.filter((skill) => !skill.deviceId || skill.deviceId === selectedDeviceId) + : availableSkills; + return available.map((skill) => ({ + value: skill.skillId, + label: skillLabel(skill), + })); + }, [selectedDeviceId, availableSkills]); + + const trustedSourceOptions = useMemo( + () => + trustedSources + .map((source) => { + const value = text(source.trustedSourceId ?? source.id, ""); + const label = text(source.label ?? source.name ?? source.trustedSourceId ?? source.id, value); + return { value, label }; + }) + .filter((option) => option.value), + [trustedSources], + ); + + const catalogItems = useMemo( + () => (availableSkillCatalog.length > 0 ? availableSkillCatalog : deriveCatalog(availableSkills)), + [availableSkillCatalog, availableSkills], + ); + + const selectedSkill = useMemo( + () => catalogItems.find((item) => item.name === selectedSkillName) ?? catalogItems[0], + [catalogItems, selectedSkillName], + ); + + const deviceById = useMemo(() => { + const next = new Map(); + for (const device of availableDevices) { + next.set(deviceIdOf(device), device); + } + return next; + }, [availableDevices]); + + const activeDeviceIds = useMemo( + () => (selectedSkill?.devices ?? []).map((device) => device.deviceId).filter(Boolean), + [selectedSkill], + ); + + const selectedSkillRequests = useMemo(() => { + if (!selectedSkill) return requests.slice(0, 6); + const selectedSkillIds = new Set((selectedSkill.devices ?? []).map((device) => device.skillId)); + const selectedDevices = new Set(activeDeviceIds); + return requests + .filter((request) => { + if (request.skillId && selectedSkillIds.has(request.skillId)) return true; + if (request.deviceId && selectedDevices.has(request.deviceId)) return true; + return false; + }) + .slice(0, 6); + }, [activeDeviceIds, requests, selectedSkill]); + + async function refreshRequests() { + setLoadingRequests(true); + setError(""); + try { + const response = await fetch(skillLifecycleRequestsEndpoint, { + method: "GET", + credentials: "include", + cache: "no-store", + }); + if (!response.ok) { + throw new Error(await readError(response)); + } + + const payload = (await response.json()) as { + ok?: boolean; + requests?: AdminSkillLifecycleRequest[]; + message?: string; + }; + if (payload.ok === false) { + throw new Error(payload.message || "请求列表读取失败"); + } + setRequests(Array.isArray(payload.requests) ? payload.requests : []); + } catch (nextError) { + const messageText = nextError instanceof Error ? nextError.message : "请求列表读取失败"; + setError(messageText); + messageApi.error(messageText); + } finally { + setLoadingRequests(false); + } + } + + async function refreshTargets() { + const response = await fetch(adminAccessEndpoint, { + credentials: "include", + cache: "no-store", + }); + if (!response.ok) { + throw new Error(await readError(response)); + } + const payload = (await response.json()) as { + ok?: boolean; + devices?: AdminSkillLifecycleDevice[]; + skills?: AdminSkillLifecycleSkill[]; + skillCatalog?: AdminSkillLifecycleCatalogItem[]; + message?: string; + }; + if (payload.ok === false) { + throw new Error(payload.message || "治理目标读取失败"); + } + setAvailableDevices(Array.isArray(payload.devices) ? payload.devices : []); + setAvailableSkills(Array.isArray(payload.skills) ? payload.skills : []); + setAvailableSkillCatalog(Array.isArray(payload.skillCatalog) ? payload.skillCatalog : []); + } + + useEffect(() => { + let active = true; + const shouldLoadTargets = devices.length === 0 && skills.length === 0 && skillCatalog.length === 0; + const tasks = [ + requests.length === 0 ? refreshRequests() : Promise.resolve(), + shouldLoadTargets ? refreshTargets() : Promise.resolve(), + ]; + Promise.all(tasks).catch((nextError) => { + if (!active) return; + const messageText = nextError instanceof Error ? nextError.message : "Skill 治理数据读取失败"; + setError(messageText); + messageApi.error(messageText); + }); + return () => { + active = false; + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + async function createRequest(values: LifecycleFormValues) { + const payload = compact(values); + if (!payload.deviceId) { + throw new Error("请选择目标设备"); + } + if (values.action === "install" && !payload.sourceUrl && !payload.trustedSourceId && !payload.trustedSource) { + throw new Error("安装请求需要填写 sourceUrl 或 trustedSourceId"); + } + if (values.action !== "install" && !payload.skillId) { + throw new Error("该操作需要选择已有 Skill"); + } + + const response = await fetch(skillLifecycleRequestsEndpoint, { + method: "POST", + credentials: "include", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(payload), + }); + if (!response.ok) { + throw new Error(await readError(response)); + } + + const result = (await response.json()) as { ok?: boolean; message?: string }; + if (result.ok === false) { + throw new Error(result.message || "请求创建失败"); + } + } + + async function submitForm() { + setSubmitting(true); + setError(""); + try { + const values = await form.validateFields(); + await createRequest(values); + messageApi.success("Skill 生命周期请求已创建"); + form.setFieldsValue({ + sourceUrl: undefined, + trustedSource: undefined, + trustedSourceId: undefined, + checksum: undefined, + expectedChecksum: undefined, + targetVersion: undefined, + rollbackToVersion: undefined, + lockedVersion: undefined, + note: undefined, + }); + await refreshRequests(); + } catch (nextError) { + const messageText = nextError instanceof Error ? nextError.message : "请求创建失败"; + setError(messageText); + messageApi.error(messageText); + } finally { + setSubmitting(false); + } + } + + const columns: ColumnsType = [ + { + title: "请求", + dataIndex: "action", + width: 170, + render: (_, request) => { + const meta = actionMeta(request.action); + return ( + + {meta.label} + + {text(request.requestId ?? request.id)} + + + ); + }, + }, + { + title: "目标", + dataIndex: "deviceId", + render: (_, request) => ( + + {text(request.deviceId)} + + {text(request.skillId ?? request.sourceUrl ?? request.trustedSourceId ?? request.trustedSource)} + + + ), + }, + { + title: "版本 / 来源", + dataIndex: "targetVersion", + render: (_, request) => ( + + {text(request.targetVersion ?? request.rollbackToVersion ?? request.lockedVersion)} + + {text(request.checksum ?? request.expectedChecksum ?? request.sourceUrl ?? request.trustedSourceId)} + + + ), + }, + { + title: "状态", + dataIndex: "status", + width: 130, + render: (status) => { + const value = text(status, "pending"); + return {value}; + }, + }, + { + title: "发起", + dataIndex: "requestedAt", + width: 190, + render: (_, request) => ( + + {text(request.requestedBy)} + + {text(request.requestedAt ?? request.updatedAt)} + + + ), + }, + { + title: "结果", + dataIndex: "resultSummary", + render: (_, request) => text(request.resultSummary ?? request.error ?? request.note), + }, + ]; + + const currentAction = actionMeta(action); + const needsExistingSkill = action !== "install"; + const showsSource = action === "install" || action === "update"; + const showsChecksum = action === "install" || action === "update"; + const recentRequests = requests.slice(0, 3); + const pendingRequests = requests.filter((request) => text(request.status, "pending") === "pending").length; + const failedRequests = requests.filter((request) => text(request.status, "").includes("failed")).length; + const activeDeviceRows = selectedSkill?.devices ?? []; + const checksumRows = [ + { key: "来源", value: showsSource ? "sourceUrl / trustedSourceId" : "既有 Skill" }, + { key: "校验", value: showsChecksum ? "checksum / expectedChecksum" : "按请求记录追踪" }, + { key: "回滚", value: "请求记录追踪" }, + ]; + + return ( +
+ {messageContext} +
{panelSubtitle}
+
+
+
+ + Skill 中心 + + + 目录优先:先确认 Skill、版本、授权对象和执行轨迹,再发起安装、更新、回滚或锁版。 + +
+ +
+
+ {[ + { label: "Skill 目录", value: catalogItems.length, tone: "text-[#06C167]" }, + { label: "授权对象", value: activeDeviceIds.length, tone: "text-[#1677FF]" }, + { label: "待处理请求", value: pendingRequests, tone: "text-[#FA8C16]" }, + { label: "失败请求", value: failedRequests, tone: "text-[#FF4D4F]" }, + ].map((item) => ( +
+
{item.label}
+
{item.value}
+
+ ))} +
+
+ +
+ + {catalogItems.length === 0 ? ( + + ) : ( +
+ {catalogItems.map((item) => { + const selected = selectedSkill?.name === item.name; + const firstDevice = item.devices?.[0]; + return ( + + ); + })} +
+ )} +
+ +
+ + {selectedSkill ? ( +
+
+ + {selectedSkill.name} + + + {text(selectedSkill.description, "暂无说明,建议补齐 SKILL.md 的 description,方便企业管理员判断用途。")} + +
+
+
+
调用方式
+
{text(selectedSkill.invocation)}
+
+
+
覆盖设备
+
{activeDeviceIds.length} 台
+
+
+
最近同步
+
+ {text(activeDeviceRows[0]?.updatedAt)} +
+
+
+
+ ) : ( + + )} +
+ + +
`${row.deviceId}-${row.skillId}`} + pagination={false} + size="small" + dataSource={activeDeviceRows} + columns={[ + { + title: "设备", + render: (_, row) => { + const device = deviceById.get(row.deviceId); + return ( + + {text(device?.name ?? device?.deviceName ?? row.deviceId)} + + {row.deviceId} + + + ); + }, + }, + { + title: "状态", + width: 110, + render: (_, row) => { + const status = text(deviceById.get(row.deviceId)?.status ?? deviceById.get(row.deviceId)?.onlineStatus); + return {status}; + }, + }, + { title: "路径", dataIndex: "path", render: (value) => text(value) }, + { title: "分类", dataIndex: "category", width: 140, render: (value) => text(value) }, + ]} + /> + + + +
text(request.requestId ?? request.id ?? index, String(index))} + pagination={false} + size="small" + dataSource={selectedSkillRequests} + columns={[ + { title: "动作", render: (_, request) => {actionMeta(request.action).label} }, + { title: "设备", dataIndex: "deviceId", render: (value) => text(value) }, + { + title: "状态", + render: (_, request) => { + const value = text(request.status, "pending"); + return {value}; + }, + }, + { title: "时间", render: (_, request) => requestTime(request) }, + { title: "结果", render: (_, request) => text(request.resultSummary ?? request.error ?? request.note) }, + ]} + /> + + + + {currentAction.label}}> + + + + form={form} + layout="vertical" + className="mt-4" + initialValues={{ action: "install" }} + onValuesChange={(changedValues, allValues) => { + if (changedValues.action) { + const nextAction = allValues.action ?? "install"; + setAction(nextAction); + const firstDevice = selectedSkill?.devices?.[0]; + if (nextAction !== "install" && firstDevice) { + setSelectedDeviceId(firstDevice.deviceId); + form.setFieldsValue({ deviceId: firstDevice.deviceId, skillId: firstDevice.skillId }); + } + } + if (Object.prototype.hasOwnProperty.call(changedValues, "deviceId")) { + setSelectedDeviceId(allValues.deviceId ?? ""); + form.setFieldValue("skillId", undefined); + } + }} + onFinish={() => void submitForm()} + > + + + + + {needsExistingSkill ? ( + + + + + + text(option?.label ?? option?.value, "") + .toLowerCase() + .includes(inputValue.toLowerCase()) + } + /> + + + + + + ) : null} + + {action === "update" ? ( + + + + ) : null} + + {action === "rollback" ? ( + + + + ) : null} + + {action === "version_lock" ? ( + + + + ) : null} + + {showsChecksum ? ( + <> + 校验 + + + + + + + + ) : null} + + + + + + {error ? : null} + + + + + + +
+ void refreshRequests()} loading={loadingRequests}> + 刷新 + + } + > +
+ text(request.requestId ?? request.id ?? `${request.action}-${request.deviceId}-${index}`, String(index)) + } + columns={columns} + dataSource={requests} + loading={loadingRequests} + pagination={{ pageSize: 8 }} + size="small" + scroll={{ x: 980 }} + /> + +
+ +
text(request.requestId ?? request.id ?? index, String(index))} + pagination={false} + size="small" + dataSource={recentRequests} + columns={[ + { title: "设备", render: (_, request) => text(request.deviceId) }, + { title: "动作", render: (_, request) => actionMeta(request.action).label }, + { + title: "状态", + render: (_, request) => { + const value = text(request.status, "pending"); + return {value}; + }, + }, + ]} + /> + + +
+ + + + + ); +} diff --git a/src/components/admin/boss-admin-app.tsx b/src/components/admin/boss-admin-app.tsx new file mode 100644 index 0000000..25b6fc5 --- /dev/null +++ b/src/components/admin/boss-admin-app.tsx @@ -0,0 +1,721 @@ +"use client"; + +import { useEffect, useState } from "react"; +import type { ReactNode } from "react"; +import { Refine } from "@refinedev/core"; +import { + Alert, + Button, + Card, + ConfigProvider, + Empty, + Input, + Statistic, + Table, + Tabs, + Tag, + theme, + message, +} from "antd"; +import zhCN from "antd/locale/zh_CN"; +import type { ColumnsType } from "antd/es/table"; +import { AdminAccessPanel } from "@/components/admin/admin-access-panel"; +import { AdminSkillLifecyclePanel } from "@/components/admin/admin-skill-lifecycle-panel"; +import { + type BossAdminOverview, + createBossAdminDataProvider, +} from "@/components/admin/boss-admin-data-provider"; + +type AdminRow = Record; + +type BossAdminAppProps = { + initialOverview?: BossAdminOverview | null; +}; + +type AdminSection = "dashboard" | "customers" | "permissions" | "governance"; +type RiskAction = "ack" | "resolve" | "create_repair_ticket" | "assign_owner" | "set_sla"; + +const resources = [ + { name: "companies", list: "/admin#companies", meta: { label: "公司" } }, + { name: "accounts", list: "/admin#accounts", meta: { label: "账号" } }, + { name: "devices", list: "/admin#devices", meta: { label: "设备" } }, + { name: "risks", list: "/admin#risks", meta: { label: "风险" } }, + { name: "notifications", list: "/admin#notifications", meta: { label: "通知" } }, + { name: "auditLogs", list: "/admin#auditLogs", meta: { label: "审计日志" } }, +]; + +const adminShell = "min-h-screen bg-[#F3F5F2] p-5 text-[#101814]"; +const adminChrome = + "mx-auto grid min-h-[calc(100vh-40px)] max-w-[1680px] grid-cols-[248px_minmax(0,1fr)] overflow-hidden rounded-[30px] border border-[#E0E6E1] bg-white shadow-[0_32px_100px_rgba(22,37,28,0.10)]"; +const adminSidebar = "border-r border-[#E3E8E4] bg-[#FBFCFB] px-4 py-5"; +const adminHeader = "flex min-h-[86px] items-center border-b border-[#E3E8E4] bg-white px-7"; +const adminCardClass = "boss-admin-card border-[#E3E8E4] shadow-[0_14px_42px_rgba(20,35,25,0.045)]"; +const adminDense = "boss-admin-dense"; + +const navItems: Array<{ + key: AdminSection; + title: string; + subtitle: string; + marker: string; +}> = [ + { key: "dashboard", title: "平台运营驾驶舱", subtitle: "全局健康与待处理事项", marker: "D" }, + { key: "customers", title: "客户与账号", subtitle: "公司、老板账号与子账号", marker: "C" }, + { key: "permissions", title: "授权工作台", subtitle: "设备、项目与 Skill 权限", marker: "P" }, + { key: "governance", title: "风险与治理", subtitle: "风险、SLA、Skill", marker: "R" }, +]; + +function text(value: unknown, fallback = "-") { + if (value === null || value === undefined || value === "") return fallback; + return String(value); +} + +function numberValue(value: unknown) { + return typeof value === "number" && Number.isFinite(value) ? value : 0; +} + +function rowId(row: AdminRow, index?: number) { + return text(row.id ?? row.companyId ?? row.account ?? row.deviceId ?? row.riskId ?? row.auditId, String(index ?? 0)); +} + +function statusTag(value: unknown) { + const status = text(value, "unknown"); + const color = + status === "online" || status === "active" || status === "healthy" || status === "completed" + ? "green" + : status === "offline" || status === "disabled" + ? "default" + : status === "failed" || status === "critical" + ? "red" + : "orange"; + return {status}; +} + +function severityTag(value: unknown) { + const severity = text(value, "info"); + const color = severity === "critical" || severity === "high" ? "red" : severity === "warning" || severity === "medium" ? "orange" : "blue"; + return {severity}; +} + +function riskTarget(row: AdminRow) { + return text(row.target ?? row.deviceId ?? row.projectId ?? row.account ?? row.companyId); +} + +function sectionTitle(section: AdminSection) { + return navItems.find((item) => item.key === section)?.title ?? "平台运营驾驶舱"; +} + +function currentSubtitle(section: AdminSection) { + return navItems.find((item) => item.key === section)?.subtitle ?? "全局健康与待处理事项"; +} + +function customerHealthTone(company: AdminRow) { + const riskCount = numberValue(company.openRiskCount); + const deviceCount = numberValue(company.deviceCount); + const onlineCount = numberValue(company.onlineDeviceCount); + if (riskCount >= 3) return { label: "需介入", color: "red" }; + if (deviceCount > 0 && onlineCount === 0) return { label: "离线", color: "orange" }; + if (riskCount > 0) return { label: "观察", color: "gold" }; + return { label: "健康", color: "green" }; +} + +const riskColumns: ColumnsType = [ + { title: "风险", dataIndex: "title", render: (_, row) => text(row.title ?? row.name ?? row.kind) }, + { title: "级别", dataIndex: "severity", width: 104, render: severityTag }, + { title: "对象", dataIndex: "target", width: 170, render: (_, row) => riskTarget(row) }, + { title: "负责人", dataIndex: "ownerAccount", width: 150, render: (_, row) => text(row.ownerAccount, "未指派") }, + { title: "SLA", dataIndex: "slaDueAt", width: 180, render: (_, row) => text(row.slaDueAt, "未设置") }, + { title: "状态", dataIndex: "status", width: 104, render: statusTag }, +]; + +const deviceColumns: ColumnsType = [ + { title: "设备", dataIndex: "name", render: (_, row) => text(row.name ?? row.deviceName ?? row.deviceId ?? row.id) }, + { title: "状态", dataIndex: "status", width: 105, render: (_, row) => statusTag(row.status ?? row.onlineStatus) }, + { title: "GUI", dataIndex: "codexGuiOnline", width: 86, render: (_, row) => statusTag(row.codexGuiOnline ? "online" : "offline") }, + { title: "CLI", dataIndex: "codexCliOnline", width: 86, render: (_, row) => statusTag(row.codexCliOnline ? "online" : "offline") }, + { title: "风险", dataIndex: "openRiskCount", width: 86, render: numberValue }, + { title: "最近心跳", dataIndex: "lastSeenAt", width: 210, render: (_, row) => text(row.lastSeenAt ?? row.updatedAt) }, +]; + +const companyColumns: ColumnsType = [ + { title: "公司", dataIndex: "name", render: (_, row) => text(row.name ?? row.companyName ?? row.companyId) }, + { title: "健康", dataIndex: "health", width: 100, render: (_, row) => { + const tone = customerHealthTone(row); + return {tone.label}; + } }, + { title: "账号", dataIndex: "accountCount", width: 86, render: numberValue }, + { title: "在线设备", dataIndex: "onlineDeviceCount", width: 112, render: (_, row) => `${numberValue(row.onlineDeviceCount)}/${numberValue(row.deviceCount)}` }, + { title: "开放风险", dataIndex: "openRiskCount", width: 104, render: numberValue }, + { title: "客户成功", dataIndex: "successOwnerAccount", width: 150, render: (_, row) => text(row.successOwnerAccount, "未指派") }, +]; + +const accountColumns: ColumnsType = [ + { title: "账号", dataIndex: "account", render: (_, row) => text(row.account ?? row.phone ?? row.id) }, + { title: "角色", dataIndex: "role", width: 130, render: statusTag }, + { title: "公司", dataIndex: "companyName", render: (_, row) => text(row.companyName ?? row.companyId) }, + { title: "状态", dataIndex: "status", width: 118, render: statusTag }, + { title: "最近登录", dataIndex: "lastLoginAt", width: 210, render: (_, row) => text(row.lastLoginAt, "暂无") }, +]; + +const notificationColumns: ColumnsType = [ + { title: "通知", dataIndex: "title", render: (_, row) => text(row.title ?? row.kind) }, + { title: "级别", dataIndex: "severity", width: 110, render: severityTag }, + { title: "公司", dataIndex: "companyId", width: 150, render: (_, row) => text(row.companyId) }, + { title: "风险", dataIndex: "riskId", width: 220, render: (_, row) => text(row.riskId) }, + { title: "时间", dataIndex: "createdAt", width: 190, render: (_, row) => text(row.createdAt) }, +]; + +async function loadOverview() { + const response = await fetch("/api/v1/admin/overview", { + credentials: "include", + cache: "no-store", + }); + + if (!response.ok) { + throw new Error(`后台总览读取失败:${response.status}`); + } + + return (await response.json()) as BossAdminOverview; +} + +function MetricCard({ + title, + value, + tone = "default", + hint, +}: { + title: string; + value: number; + tone?: "default" | "green" | "red" | "orange"; + hint?: string; +}) { + const valueColor = tone === "green" ? "#07A85A" : tone === "red" ? "#E23D3D" : tone === "orange" ? "#D97706" : "#101814"; + return ( + + + {hint ?
{hint}
: null} +
+ ); +} + +function PanelTitle({ title, subtitle, extra }: { title: string; subtitle?: string; extra?: ReactNode }) { + return ( +
+
+
{title}
+ {subtitle ?
{subtitle}
: null} +
+ {extra ?
{extra}
: null} +
+ ); +} + +function EmptyBlock({ textValue }: { textValue: string }) { + return ; +} + +type RiskActionsProps = { + selectedRisk?: AdminRow; + actionBusy: string; + onSubmit: (risk: AdminRow, action: RiskAction, extraBody?: Record) => void; +}; + +function RiskActionPanel({ selectedRisk, actionBusy, onSubmit }: RiskActionsProps) { + const [ownerAccount, setOwnerAccount] = useState(""); + const [slaDueAt, setSlaDueAt] = useState(""); + const riskId = selectedRisk ? text(selectedRisk.riskId ?? selectedRisk.id, "") : ""; + const kind = selectedRisk ? text(selectedRisk.kind, "") : ""; + const canAckResolve = kind === "ops_fault" || kind === "thread_context_alert"; + const canCreateTicket = kind === "ops_fault"; + + if (!selectedRisk) { + return ( + + + + + ); + } + + return ( + + +
+
+
+ {severityTag(selectedRisk.severity)} + {text(selectedRisk.title ?? selectedRisk.kind)} +
+
{text(selectedRisk.detail ?? selectedRisk.summary, "暂无详情")}
+
对象:{riskTarget(selectedRisk)}
+
+
+
负责人账号
+
+ setOwnerAccount(event.target.value)} placeholder="例如 ops@company.com" /> + +
+
+
+
SLA 截止时间
+
+ setSlaDueAt(event.target.value)} placeholder="2026-04-30T18:00:00+08:00" /> + +
+
+
+ + + +
+ {!canAckResolve ? ( + + ) : null} +
+
+ ); +} + +function DashboardView({ + stats, + companies, + devices, + risks, + notifications, + timeline, + onOpenRisk, +}: { + stats: AdminRow; + companies: AdminRow[]; + devices: AdminRow[]; + risks: AdminRow[]; + notifications: AdminRow[]; + timeline: AdminRow[]; + onOpenRisk: () => void; +}) { + const topRisks = risks.slice(0, 5); + const topCompanies = companies.slice().sort((left, right) => numberValue(right.openRiskCount) - numberValue(left.openRiskCount)).slice(0, 6); + return ( +
+
+ +
+ + + + + +
+
+ +
+ + +
+ {topCompanies.length > 0 ? topCompanies.map((company) => { + const tone = customerHealthTone(company); + return ( +
+
+
{text(company.name ?? company.companyName ?? company.companyId)}
+
+ 账号 {numberValue(company.accountCount)} · 设备 {numberValue(company.onlineDeviceCount)}/{numberValue(company.deviceCount)} · 客户成功 {text(company.successOwnerAccount, "未指派")} +
+
+
+ {tone.label} + {numberValue(company.openRiskCount)} 风险 +
+
+ ); + }) : } +
+
+ + + 进入战情室} + /> + {topRisks.length > 0 ? ( +
+ ) : ( + + )} + + + +
+ + +
+ + + +
+ {[...notifications, ...timeline].slice(0, 7).map((event, index) => ( +
+
+
{text(event.title ?? event.action ?? event.kind, "事件")}
+ {severityTag(event.severity ?? "info")} +
+
{text(event.createdAt ?? event.updatedAt ?? event.time, "暂无时间")}
+
+ ))} + {notifications.length === 0 && timeline.length === 0 ? : null} +
+
+ + + ); +} + +function CustomersView({ companies, accounts, devices }: { companies: AdminRow[]; accounts: AdminRow[]; devices: AdminRow[] }) { + return ( +
+
+ + +
+ + + +
+ {["创建客户公司", "开通老板账号", "绑定客户电脑", "分配项目与 Skill 权限"].map((item, index) => ( +
+ {index + 1} +
+
{item}
+
当前仍复用下方授权工作台写入接口,先保证链路稳定。
+
+
+ ))} +
+
+ +
+ + +
+ + + +
+ + + + ); +} + +function PermissionsView() { + return ( +
+ + + + +
+ ); +} + +function GovernanceView({ + risks, + notifications, + selectedRisk, + setSelectedRisk, + actionBusy, + submitRiskAction, +}: { + risks: AdminRow[]; + notifications: AdminRow[]; + selectedRisk?: AdminRow; + setSelectedRisk: (risk?: AdminRow) => void; + actionBusy: string; + submitRiskAction: (risk: AdminRow, action: RiskAction, extraBody?: Record) => void; +}) { + return ( + + + +
({ + onClick: () => setSelectedRisk(risk), + className: rowId(risk) === rowId(selectedRisk ?? {}) ? "cursor-pointer bg-[#F1FAF4]" : "cursor-pointer", + })} + /> + +
+ + + +
+ + + + ), + }, + { + key: "skills", + label: "Skill 生命周期", + children: ( + + + + + ), + }, + ]} + /> + ); +} + +export function BossAdminApp({ initialOverview = null }: BossAdminAppProps) { + const [overview, setOverview] = useState(initialOverview); + const [error, setError] = useState(""); + const [actionBusy, setActionBusy] = useState(""); + const [activeSection, setActiveSection] = useState("dashboard"); + const [selectedRiskId, setSelectedRiskId] = useState(""); + const [messageApi, messageContext] = message.useMessage(); + + useEffect(() => { + if (overview) return; + + let active = true; + loadOverview() + .then((nextOverview) => { + if (active) setOverview(nextOverview); + }) + .catch((nextError: Error) => { + if (active) setError(nextError.message); + }); + + return () => { + active = false; + }; + }, [overview]); + + const stats = overview?.summary ?? overview?.stats ?? {}; + const companies = overview?.companies ?? []; + const accounts = overview?.accounts ?? []; + const devices = overview?.devices ?? []; + const risks = overview?.risks ?? []; + const notifications = overview?.notifications ?? []; + const timeline = Array.isArray((overview as { riskTimeline?: AdminRow[] } | null)?.riskTimeline) + ? ((overview as { riskTimeline?: AdminRow[] }).riskTimeline ?? []) + : []; + const selectedRisk = risks.find((risk) => rowId(risk) === selectedRiskId) ?? risks[0]; + + async function refreshOverview() { + setOverview(await loadOverview()); + } + + async function submitRiskAction(risk: AdminRow, action: RiskAction, extraBody: Record = {}) { + const riskId = text(risk.riskId ?? risk.id, ""); + if (!riskId) return; + setActionBusy(`${riskId}:${action}`); + setError(""); + try { + const response = await fetch("/api/v1/admin/risks/actions", { + method: "POST", + credentials: "include", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ riskId, action, ...extraBody }), + }); + const payload = (await response.json().catch(() => null)) as { ok?: boolean; message?: string } | null; + if (!response.ok || payload?.ok === false) { + throw new Error(payload?.message || `风险动作失败:${response.status}`); + } + messageApi.success( + action === "ack" + ? "已确认风险" + : action === "resolve" + ? "已关闭风险" + : action === "assign_owner" + ? "已指派负责人" + : action === "set_sla" + ? "已设置 SLA" + : "已创建修复工单", + ); + await refreshOverview(); + } catch (nextError) { + const messageText = nextError instanceof Error ? nextError.message : "风险动作失败"; + setError(messageText); + messageApi.error(messageText); + } finally { + setActionBusy(""); + } + } + + function renderActiveSection() { + if (activeSection === "customers") { + return ; + } + if (activeSection === "permissions") { + return ; + } + if (activeSection === "governance") { + return ( + setSelectedRiskId(risk ? rowId(risk) : "")} + actionBusy={actionBusy} + submitRiskAction={(risk, action, extraBody) => void submitRiskAction(risk, action, extraBody)} + /> + ); + } + return ( + setActiveSection("governance")} + /> + ); + } + + return ( + + +
+ {messageContext} +
+ +
+
+
+
{sectionTitle(activeSection)}
+
{currentSubtitle(activeSection)}
+
+
+ +
+ 平台最高管理员 +
+
+
+
+ {error ? : null} + {renderActiveSection()} +
+
+
+
+
+
+ ); +} diff --git a/src/components/admin/boss-admin-data-provider.ts b/src/components/admin/boss-admin-data-provider.ts new file mode 100644 index 0000000..f980ef5 --- /dev/null +++ b/src/components/admin/boss-admin-data-provider.ts @@ -0,0 +1,121 @@ +import type { + BaseRecord, + CreateResponse, + DataProvider, + DeleteOneResponse, + DeleteOneParams, + GetListResponse, + GetListParams, + GetOneResponse, + GetOneParams, + CreateParams, + UpdateParams, + UpdateResponse, +} from "@refinedev/core"; + +export type BossAdminSeverity = "critical" | "high" | "medium" | "low" | "info"; + +export type BossAdminOverview = { + ok?: boolean; + summary?: { + companies?: number; + accounts?: number; + devices?: number; + onlineDevices?: number; + openRisks?: number; + openNotifications?: number; + criticalRisks?: number; + }; + stats?: { + companies?: number; + accounts?: number; + devices?: number; + onlineDevices?: number; + openRisks?: number; + openNotifications?: number; + criticalRisks?: number; + }; + companies?: Array>; + accounts?: Array>; + devices?: Array>; + risks?: Array>; + notifications?: Array>; + auditLogs?: Array>; +}; + +const resourceKeys = new Set(["companies", "accounts", "devices", "risks", "notifications", "auditLogs"]); + +async function fetchOverview() { + const response = await fetch("/api/v1/admin/overview", { + credentials: "include", + cache: "no-store", + }); + + if (!response.ok) { + throw new Error(`Failed to load admin overview: ${response.status}`); + } + + return (await response.json()) as BossAdminOverview; +} + +function listFromOverview(overview: BossAdminOverview | undefined, resource: string) { + if (!resourceKeys.has(resource)) return []; + const value = overview?.[resource as keyof BossAdminOverview]; + return Array.isArray(value) ? value : []; +} + +function recordId(item: Record, index: number) { + return String(item.id ?? item.companyId ?? item.account ?? item.deviceId ?? item.riskId ?? item.auditId ?? index); +} + +export function createBossAdminDataProvider(initialOverview?: BossAdminOverview): DataProvider { + let overviewCache = initialOverview; + + return { + getList: async ({ resource }: GetListParams) => { + if (!overviewCache) { + overviewCache = await fetchOverview(); + } + + const data = listFromOverview(overviewCache, resource).map((item, index) => ({ + id: recordId(item, index), + ...item, + })) as TData[]; + + return { + data, + total: data.length, + } satisfies GetListResponse; + }, + getOne: async ({ resource, id }: GetOneParams) => { + if (!overviewCache) { + overviewCache = await fetchOverview(); + } + + const item = listFromOverview(overviewCache, resource).find((entry, index) => { + return recordId(entry, index) === String(id); + }); + + return { + data: { + id, + ...(item ?? {}), + } as TData, + } satisfies GetOneResponse; + }, + create: async ({ + variables, + }: CreateParams) => + ({ data: variables as unknown as TData }) satisfies CreateResponse, + update: async ({ + id, + variables, + }: UpdateParams) => + ({ data: { id, ...(variables as BaseRecord) } as TData }) satisfies UpdateResponse, + deleteOne: async ({ + id, + }: DeleteOneParams) => + ({ data: { id } as TData }) satisfies DeleteOneResponse, + getApiUrl: () => "/api/v1/admin/overview", + }; +} diff --git a/src/components/app-ui.tsx b/src/components/app-ui.tsx index 177e695..784c6c0 100644 --- a/src/components/app-ui.tsx +++ b/src/components/app-ui.tsx @@ -77,6 +77,12 @@ export function buildDeviceWorkspaceDetailCards(workspace: DeviceWorkspaceView) items: { gui: `GUI:${selectedDevice?.capabilities?.gui?.connected ? "已连接" : "未连接"}`, cli: `CLI:${selectedDevice?.capabilities?.cli?.connected ? "已连接" : "未连接"}`, + browserAutomation: `浏览器自动化:${ + selectedDevice?.capabilities?.browserAutomation?.connected ? "已连接" : "未连接" + }`, + computerUse: `桌面控制:${ + selectedDevice?.capabilities?.computerUse?.connected ? "已连接" : "未连接" + }`, preferredExecutionMode: `默认执行模式:${ selectedDevice?.preferredExecutionMode === "gui" ? "GUI" @@ -116,12 +122,17 @@ async function waitForLoginSessionReady(nativeClient: boolean) { return false; } -function navigateToConversations(router: ReturnType) { - router.replace("/conversations", { scroll: false }); +function resolvePostLoginPath() { + return window.location.hostname === "admin.boss.hyzq.net" ? "/admin" : "/conversations"; +} + +function navigateAfterLogin(router: ReturnType) { + const targetPath = resolvePostLoginPath(); + router.replace(targetPath, { scroll: false }); router.refresh(); window.setTimeout(() => { - if (window.location.pathname !== "/conversations") { - window.location.replace("/conversations"); + if (window.location.pathname !== targetPath) { + window.location.replace(targetPath); } }, 180); } @@ -686,6 +697,12 @@ export function DeviceEditorCard({
{detailCards.capabilities.items.gui}
{detailCards.capabilities.items.cli}
+
+ {detailCards.capabilities.items.browserAutomation} +
+
+ {detailCards.capabilities.items.computerUse} +
{detailCards.capabilities.items.preferredExecutionMode}
@@ -1995,7 +2012,7 @@ export function AuthForm({ } await waitForLoginSessionReady(nativeClient); - navigateToConversations(router); + navigateAfterLogin(router); return; } if (result.ok && mode === "register") { @@ -2067,8 +2084,7 @@ export function AuthForm({ <>
- 当前固定验证码模式下,可直接输入 000000{" "} - 登录;如需确认账号状态,也可以先点“发送验证码”。 + 验证码会按当前服务器配置发送;如果企业仍处于固定验证码演示模式,请以管理员配置为准。
) : null} @@ -2230,7 +2246,7 @@ function Field({ export function DeviceEnrollmentBuilder() { const [name, setName] = useState("Mac Mini"); const [avatar, setAvatar] = useState("M"); - const [account, setAccount] = useState("17600003315"); + const [account, setAccount] = useState("krisolo"); const [projects, setProjects] = useState(""); const [endpoint, setEndpoint] = useState("mac://new-device.local"); const [note, setNote] = useState("新设备待绑定"); diff --git a/src/components/session-management-client.tsx b/src/components/session-management-client.tsx new file mode 100644 index 0000000..f1e9fcf --- /dev/null +++ b/src/components/session-management-client.tsx @@ -0,0 +1,118 @@ +"use client"; + +import { useState } from "react"; +import { useRouter } from "next/navigation"; +import clsx from "clsx"; + +type SessionSummary = { + sessionId: string; + account: string; + role: string; + displayName: string; + loginMethod: string; + createdAt: string; + expiresAt: string; + lastSeenAt: string; + current: boolean; +}; + +function formatTime(value: string) { + return new Date(value).toLocaleString("zh-CN", { hour12: false }); +} + +export function SessionManagementClient({ initialSessions }: { initialSessions: SessionSummary[] }) { + const router = useRouter(); + const [sessions, setSessions] = useState(initialSessions); + const [busySessionId, setBusySessionId] = useState(""); + const [message, setMessage] = useState(""); + + async function refresh() { + const response = await fetch("/api/v1/auth/sessions", { cache: "no-store" }); + const result = (await response.json()) as { ok: boolean; sessions?: SessionSummary[]; message?: string }; + if (!response.ok || !result.ok) { + throw new Error(result.message ?? "刷新失败"); + } + setSessions(result.sessions ?? []); + } + + async function revoke(sessionId: string, current: boolean) { + setBusySessionId(sessionId); + setMessage(""); + try { + const response = await fetch("/api/v1/auth/sessions", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ action: "revoke_session", sessionId }), + }); + const result = (await response.json()) as { ok: boolean; message?: string }; + if (!response.ok || !result.ok) { + throw new Error(result.message ?? "撤销失败"); + } + if (current) { + router.replace("/auth/login"); + router.refresh(); + return; + } + await refresh(); + setMessage("会话已撤销。"); + } catch (error) { + setMessage(error instanceof Error ? error.message : "撤销失败"); + } finally { + setBusySessionId(""); + } + } + + return ( +
+
登录会话
+
+ 管理当前账号的登录端;最高管理员可看到所有账号的会话。 +
+
+ {sessions.length === 0 ? ( +
+ 暂无可管理会话。 +
+ ) : ( + sessions.map((session) => ( +
+
+
+
+
+ {session.displayName || session.account} +
+ {session.current ? ( + + 当前 + + ) : null} +
+
+ {session.account} · {session.loginMethod === "code" ? "验证码" : "账号密码"} +
+ 最近活跃:{formatTime(session.lastSeenAt)} +
+ 到期:{formatTime(session.expiresAt)} +
+
+ +
+
+ )) + )} +
+ {message ?
{message}
: null} +
+ ); +} diff --git a/src/components/telegram-integration-client.tsx b/src/components/telegram-integration-client.tsx new file mode 100644 index 0000000..1d95a02 --- /dev/null +++ b/src/components/telegram-integration-client.tsx @@ -0,0 +1,466 @@ +"use client"; + +import { useMemo, useState } from "react"; +import { useRouter } from "next/navigation"; +import clsx from "clsx"; + +type TelegramGatewayView = { + enabled: boolean; + mode: "webhook" | "polling"; + botTokenConfigured: boolean; + botUsername?: string; + dmPolicy: "allowlist" | "open" | "disabled"; + allowFrom: string[]; + groupPolicy: "allowlist" | "open" | "disabled"; + groups: string[]; + requireMentionInGroups: boolean; + defaultProjectId: string; + groupProjectRoutes: Array<{ chatId: string; threadId?: number; projectId: string; label?: string }>; + webhookSecretConfigured: boolean; + webhookUrl?: string; + lastConfiguredAt?: string; + lastConfiguredBy?: string; + lastError?: string; + processedUpdateCount: number; +}; + +type Draft = { + enabled: boolean; + mode: "webhook" | "polling"; + botToken: string; + dmPolicy: "allowlist" | "open" | "disabled"; + allowFromText: string; + groupPolicy: "allowlist" | "open" | "disabled"; + groupsText: string; + requireMentionInGroups: boolean; + defaultProjectId: string; + groupProjectRoutesText: string; + webhookSecret: string; + webhookUrl: string; +}; + +function draftFromView(view: TelegramGatewayView): Draft { + return { + enabled: view.enabled, + mode: view.mode, + botToken: "", + dmPolicy: view.dmPolicy, + allowFromText: view.allowFrom.join("\n"), + groupPolicy: view.groupPolicy, + groupsText: view.groups.join("\n"), + requireMentionInGroups: view.requireMentionInGroups, + defaultProjectId: view.defaultProjectId, + groupProjectRoutesText: formatGroupProjectRoutes(view.groupProjectRoutes), + webhookSecret: "", + webhookUrl: view.webhookUrl ?? "", + }; +} + +function parseLines(value: string) { + return value + .split(/\r?\n/) + .map((item) => item.trim()) + .filter(Boolean); +} + +function formatGroupProjectRoutes(routes: TelegramGatewayView["groupProjectRoutes"]) { + return routes + .map((route) => { + const chatPart = route.threadId != null ? `${route.chatId}#${route.threadId}` : route.chatId; + return [chatPart, route.projectId, route.label].filter(Boolean).join(" "); + }) + .join("\n"); +} + +function parseGroupProjectRoutes(value: string) { + return parseLines(value) + .map((line) => { + const [chatAndTopic, projectId, ...labelParts] = line.split(/\s+/); + if (!chatAndTopic || !projectId) { + return null; + } + const [chatId, threadIdRaw] = chatAndTopic.split("#"); + const threadId = Number(threadIdRaw); + return { + chatId, + ...(threadIdRaw && Number.isFinite(threadId) ? { threadId } : {}), + projectId, + ...(labelParts.length > 0 ? { label: labelParts.join(" ") } : {}), + }; + }) + .filter((route): route is { chatId: string; threadId?: number; projectId: string; label?: string } => + Boolean(route?.chatId && route.projectId), + ); +} + +function SectionTitle({ title, note }: { title: string; note?: string }) { + return ( +
+
{title}
+ {note ?
{note}
: null} +
+ ); +} + +function TextField({ + label, + value, + onChange, + placeholder, + secret = false, +}: { + label: string; + value: string; + onChange: (value: string) => void; + placeholder?: string; + secret?: boolean; +}) { + return ( + + ); +} + +function TextAreaField({ + label, + value, + onChange, + placeholder, +}: { + label: string; + value: string; + onChange: (value: string) => void; + placeholder?: string; +}) { + return ( +