From f417fe19552943478a71ad600f9da10a504baaad Mon Sep 17 00:00:00 2001 From: kris Date: Tue, 31 Mar 2026 05:18:58 +0800 Subject: [PATCH] feat: harden agent onboarding and device import flows --- README.md | 10 +- android/app/build.gradle | 4 +- .../com/hyzq/boss/AiAccountsActivity.java | 60 ++- .../hyzq/boss/DeviceEnrollmentActivity.java | 7 +- .../hyzq/boss/DeviceImportDraftActivity.java | 97 ++++- .../hyzq/boss/OpenAiOnboardingActivity.java | 34 +- .../com/hyzq/boss/AiAccountsActivityTest.java | 76 ++++ .../boss/DeviceImportDraftActivityTest.java | 175 +++++++++ .../boss/OpenAiOnboardingActivityTest.java | 22 +- .../api_and_service_inventory_cn.md | 3 + .../current_runtime_and_deploy_status_cn.md | 8 +- local-agent/server.mjs | 22 +- .../v1/projects/[projectId]/messages/route.ts | 70 ++-- .../device-import-draft-manager.tsx | 349 ++++++++++++++---- src/lib/boss-data.ts | 11 + tests/device-import-draft-manager.test.ts | 83 +++++ tests/device-import-draft.test.ts | 6 + tests/group-message-dispatch-plan.test.ts | 70 ++++ 18 files changed, 975 insertions(+), 132 deletions(-) create mode 100644 android/app/src/test/java/com/hyzq/boss/DeviceImportDraftActivityTest.java create mode 100644 tests/device-import-draft-manager.test.ts diff --git a/README.md b/README.md index 8cb45b6..18740e1 100644 --- a/README.md +++ b/README.md @@ -33,7 +33,7 @@ - `src/boss_control`:空占位目录,不参与当前运行 - `src/boss_device_agent`:空占位目录,不参与当前运行 -## 当前运行状态(2026-03-30) +## 当前运行状态(2026-03-31) 本地: @@ -54,7 +54,7 @@ - `POST http://127.0.0.1:3000/api/v1/projects/[projectId]/messages` 正常,普通单线程会话当前会返回 `conversation_reply` 任务,并等待绑定设备上的真实 Codex 线程回写 - `POST http://127.0.0.1:3000/api/auth/logout` 正常,退出后访问受保护 `/api/v1/*` 会返回 `401` - `GET http://127.0.0.1:3000/api/v1/user/ota/package` 正常,当前会返回最新 APK 包 -- `GET http://127.0.0.1:4317/health` 正常 +- 当前这台开发机在本轮 `launchctl` 重载后,`GET http://127.0.0.1:4317/health` 仍未恢复;代码已改成先起本地 health 监听、再异步执行首次 heartbeat / task poll,剩余问题已收敛到 launchd 环境差异排查 - `GET http://127.0.0.1:4317/api/v1/skills` 正常,已返回本机扫描到的 Codex Skill - `POST http://127.0.0.1:4317/api/v1/heartbeat` 正常,且会顺带触发 `thread-context` 上报 - `launchd` 已加载:`~/Library/LaunchAgents/com.hyzq.boss.local-agent.plist` @@ -94,7 +94,7 @@ Android APK: - 已生成 Android debug APK:`android/app/build/outputs/apk/debug/app-debug.apk` - 已生成 Android signed release APK:`android/app/build/outputs/apk/release/app-release.apk` - `npm run apk:release` 还会额外产出带版本号的文件:`android/app/build/outputs/apk/release/boss-android-v{versionName}-release.apk` -- 当前最新 release 构建版本:`2.5.5`(`versionCode=18`) +- 当前最新 release 构建版本:`2.5.6`(`versionCode=19`) - 当前 APK 已切到原生 Android 客户端:`MainActivity + BossApiClient + 原生 XML 布局` - 当前原生活动页已经覆盖:会话首页、项目详情、项目目标、版本记录、会话信息、群资料、发起群聊、消息转发、线程详情、设备详情、添加设备、账号与安全、设置、AI 账号、技能、运维中心、关于 - 当前原生一级体验已回退到微信式交互:`会话 / 设备 / 我的` 固定底部 tab,会话首页是简单聊天列表,`主 Agent / 审计对话` 以普通置顶会话样式排在最前;项目详情页是聊天优先,只保留 `项目目标 / 版本记录` 两个轻入口 @@ -108,9 +108,11 @@ Android APK: - 当前群聊调度主链已补上第一轮业务闭环:群聊文字消息会先进入主 Agent 生成推荐下发方案,用户确认后创建真正的线程执行单,执行完成后会把线程原始结果回写到群聊,再追加一条主 Agent 汇总 - 当前 `approval_required` 群聊已补齐两条审批动作:可以确认主 Agent 推荐,也可以明确拒绝;拒绝后会把群审批状态写成 `rejected`,并在群里追加系统提示,不会继续下发到线程 - 当前原生聊天页已把待审批推荐前移到主消息流:`ProjectDetailActivity` 会直接显示 `确认下发 / 拒绝`,刷新后也能恢复最近一条待确认推荐 +- 当前 `approval_required` 群聊在已有待确认推荐时,会拒绝继续生成新的推荐,并提示用户先确认或拒绝当前推荐,避免审批消息叠加 - 当前三条聊天主链都已接入真实等待链路:`主 Agent 单聊 / 普通线程单聊 / 群聊确认下发` 当前都会返回任务信息,原生 Android 会保持等待直到收到真实回写或明确超时提示 - 当前 `我的 > AI 账号` 已补 `登录 OpenAI 平台账号` 与 `绑定 Master Codex Node` 两条显式入口;OpenAI API 登录成功后会立即设为当前主控 - 当前 `登录 OpenAI 平台账号` 已升级成浏览器辅助登录流:会先进入原生引导页,再自动打开 `OpenAI Platform` 登录页;用户登录后可直接跳到 `API Keys` 页面,回 APP 粘贴 key 完成接入 +- 当前 `AI 账号` 页顶部会显式展示“当前主控身份”,并提供 `校验主控 / 测试主 Agent 对话` 两个动作,切换主控后可直接验证聊天通路 - 当前 `OpenAiOnboardingActivity` 在登录成功后会直接给出 `测试主 Agent 对话` 入口,可一键跳到 `master-agent` 聊天页 - 当前主控若还是 `Master Codex Node`,但节点离线或执行立即失败,主 Agent 会优先尝试已配置的 `OpenAI API` 备用账号,避免聊天直接掉成失败日志 - 当前群资料页已经支持“修复群成员”:如果历史脏群里混入了 `master-agent` 或失效线程引用,前台会明确提示并允许重新选择真实线程成员,修复后会正式写回群成员账本 @@ -118,6 +120,7 @@ Android APK: - 当前 Web 群聊页也已补上待确认推荐的刷新恢复:群聊详情会在服务端读取最近一条 `pending_user_confirmation` 的 dispatch plan,并在刷新或重新进入页面后继续显示“等待你确认主 Agent 推荐” - 当前设备导入主链已补上第一轮后端闭环:设备 heartbeat 可上报真实项目候选,服务端会生成 `import draft`;用户可提交勾选结果、触发主 Agent 风格的导入决议,并把选中的线程真正落成聊天窗口 - 当前新设备导入前台已经接通:Web `添加设备` 成功后会直接进入“导入项目”步骤;设备页详情里也可再次打开导入草稿。原生 Android 端同样已补 `DeviceImportDraftActivity`,可完成 `勾选 -> 预览决议 -> 应用导入` +- 当前设备导入前台文案与状态卡已收口:会明确显示 `等待候选线程 / 等待勾选 / 建议已生成 / 已导入`,并在导入后回显真正落到会话首页的线程名 - 当前 `dispatch_execution` 完成回写已补幂等:同一个执行单重复完成,不会再向群聊重复追加线程原始回复和主 Agent 汇总 - 当前当 heartbeat 同时携带旧 `projects` 和新 `projectCandidates` 时,服务端会优先走 `import draft`,不再绕过勾选/应用阶段直接把旧项目目录导入为聊天窗口 - 当前设备导入 `review` 已补 owner/admin 鉴权,并会留下 `device_import_resolution` master task 轨迹,再把决议写回草稿和会话账本 @@ -199,6 +202,7 @@ device-agent 当前职责: - 对普通单线程会话,认领到的 `conversation_reply` 任务会直接恢复到目标 Codex 线程,并把线程原始回复回写到对应聊天窗口 - 对群聊线程分发任务,认领到的 `dispatch_execution` 任务会把原始线程结果和主 Agent 汇总一起回写到群聊消息账本 - `local-agent` 对 `conversation_reply / dispatch_execution` 当前会优先使用 `codex exec resume `,只有缺失真实线程引用时才退回 `--ephemeral` +- `local-agent` 当前会先启动本地 `4317` 健康监听,再异步执行首次 heartbeat 和 task poll,避免控制面短暂阻塞时本地健康检查一起挂死 - 如果某个历史群聊里已经没有真实线程成员,当前不会再表现成“发了没反应”,而是会在群里追加一条 `system_notice`,提示用户先重新整理群成员 - 设备导入审核当前也会落 `device_import_resolution` 任务轨迹,但决议内容仍是服务端 heuristic 版;下一阶段可再升级成真正通过 `local-agent -> codex exec` 参与理解 - 提供本地 `/health`、`/api/v1/device`、`/api/v1/skills`、`/api/v1/heartbeat` diff --git a/android/app/build.gradle b/android/app/build.gradle index fad6e77..557fb9e 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -36,8 +36,8 @@ android { applicationId "com.hyzq.boss" minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion - versionCode 18 - versionName "2.5.5" + versionCode 19 + versionName "2.5.6" buildConfigField "String", "BOSS_API_BASE_URL", "\"https://boss.hyzq.net\"" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" } 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 3ae4060..f70c030 100644 --- a/android/app/src/main/java/com/hyzq/boss/AiAccountsActivity.java +++ b/android/app/src/main/java/com/hyzq/boss/AiAccountsActivity.java @@ -78,28 +78,61 @@ public class AiAccountsActivity extends BossScreenActivity { private LinearLayout buildActiveIdentityCard(@Nullable JSONObject activeIdentity) { if (activeIdentity == null) { - return BossUi.buildWechatMenuRow( + LinearLayout empty = new LinearLayout(this); + empty.setOrientation(LinearLayout.VERTICAL); + empty.addView(BossUi.buildWechatMenuRow( this, "当前主控身份", "当前没有可用账号。", "请先新增或启用一个账号。", null, null - ); + )); + return empty; } String subtitle = activeIdentity.optString("label", "AI 账号") + " · " + activeIdentity.optString("displayName", "-"); String meta = activeIdentity.optString("roleLabel", "-") + " · " + activeIdentity.optString("providerLabel", "-") + " · " + activeIdentity.optString("statusLabel", "-"); - return BossUi.buildWechatMenuRow( + String note = activeIdentity.optString("note", ""); + String activeAccountId = activeIdentity.optString("accountId", ""); + boolean canGenerate = activeIdentity.optBoolean("canGenerate", false); + + LinearLayout card = new LinearLayout(this); + card.setOrientation(LinearLayout.VERTICAL); + card.addView(BossUi.buildWechatMenuRow( this, "当前主控身份", subtitle, meta, - null, + activeIdentity.optBoolean("isEnvironmentFallback") ? "环境" : "当前", null - ); + )); + + if (!note.isEmpty()) { + card.addView(BossUi.buildWechatMenuRow( + this, + "主控状态", + note, + activeIdentity.optString("switchReason", ""), + null, + null + )); + } + + if (!activeAccountId.isEmpty()) { + Button validate = BossUi.buildMiniActionButton(this, "校验主控", false); + validate.setOnClickListener(v -> validateAccount(activeAccountId)); + + Button testMasterAgent = BossUi.buildMiniActionButton(this, "测试主 Agent 对话", canGenerate); + testMasterAgent.setEnabled(canGenerate); + testMasterAgent.setOnClickListener(v -> openMasterAgentConversation()); + + card.addView(BossUi.buildInlineActionRow(this, validate, testMasterAgent)); + } + + return card; } private LinearLayout buildAccountsSection(@Nullable JSONArray accounts) { @@ -564,10 +597,18 @@ public class AiAccountsActivity extends BossScreenActivity { } private void validateAccount(JSONObject account) { + validateAccount(account.optString("accountId")); + } + + private void validateAccount(String accountId) { + if (accountId == null || accountId.trim().isEmpty()) { + showMessage("当前账号没有可用的账号 ID,暂时无法校验。"); + return; + } setRefreshing(true); executor.execute(() -> { try { - BossApiClient.ApiResponse response = apiClient.validateAccount(account.optString("accountId")); + BossApiClient.ApiResponse response = apiClient.validateAccount(accountId.trim()); if (!response.ok()) throw new IllegalStateException(response.message()); runOnUiThread(() -> { showMessage(response.message()); @@ -582,6 +623,13 @@ public class AiAccountsActivity extends BossScreenActivity { }); } + private void openMasterAgentConversation() { + Intent intent = new Intent(this, ProjectDetailActivity.class); + intent.putExtra(ProjectDetailActivity.EXTRA_PROJECT_ID, "master-agent"); + intent.putExtra(ProjectDetailActivity.EXTRA_PROJECT_NAME, "主 Agent"); + startActivity(intent); + } + private void confirmDeleteAccount(JSONObject account) { new AlertDialog.Builder(this) .setTitle("删除 AI 账号") diff --git a/android/app/src/main/java/com/hyzq/boss/DeviceEnrollmentActivity.java b/android/app/src/main/java/com/hyzq/boss/DeviceEnrollmentActivity.java index 1221d0a..0425f99 100644 --- a/android/app/src/main/java/com/hyzq/boss/DeviceEnrollmentActivity.java +++ b/android/app/src/main/java/com/hyzq/boss/DeviceEnrollmentActivity.java @@ -81,7 +81,7 @@ public class DeviceEnrollmentActivity extends BossScreenActivity { runOnUiThread(() -> { JSONObject enrollment = response.json.optJSONObject("enrollment"); JSONObject device = response.json.optJSONObject("device"); - android.widget.Button importButton = BossUi.buildSecondaryButton(this, "继续导入项目"); + android.widget.Button importButton = BossUi.buildSecondaryButton(this, "继续导入线程"); importButton.setOnClickListener(v -> openImportDraft(device)); replaceContent( BossUi.buildSoftPanel( @@ -90,8 +90,9 @@ public class DeviceEnrollmentActivity extends BossScreenActivity { "设备 " + (device == null ? "-" : device.optString("name", "-")) + "\npairingCode " + (enrollment == null ? "-" : enrollment.optString("pairingCode", "-")) + "\ntoken " + (enrollment == null ? "-" : enrollment.optString("token", "-")), - enrollment == null ? "ready" : enrollment.optString("status", "ready") - + " · 到期 " + enrollment.optString("expiresAt", "-") + (enrollment == null ? "ready" : enrollment.optString("status", "ready")) + + " · 到期 " + (enrollment == null ? "-" : enrollment.optString("expiresAt", "-")) + + "\n下一步:打开导入草稿,勾选线程后生成导入建议。" ), importButton ); diff --git a/android/app/src/main/java/com/hyzq/boss/DeviceImportDraftActivity.java b/android/app/src/main/java/com/hyzq/boss/DeviceImportDraftActivity.java index fea37c2..5cfdb8f 100644 --- a/android/app/src/main/java/com/hyzq/boss/DeviceImportDraftActivity.java +++ b/android/app/src/main/java/com/hyzq/boss/DeviceImportDraftActivity.java @@ -9,8 +9,10 @@ import androidx.annotation.Nullable; import org.json.JSONArray; import org.json.JSONObject; +import java.util.ArrayList; import java.util.LinkedHashMap; import java.util.LinkedHashSet; +import java.util.List; import java.util.Map; public class DeviceImportDraftActivity extends BossScreenActivity { @@ -79,10 +81,10 @@ public class DeviceImportDraftActivity extends BossScreenActivity { appendContent(BossUi.buildSoftPanel( this, "导入 Codex 项目", - (deviceName == null ? "当前设备" : deviceName) + "\n勾选要暴露到会话首页的项目和线程。", + (deviceName == null ? "当前设备" : deviceName) + "\n先勾选线程,再生成导入建议,最后应用导入。", draft == null ? "等待设备完成首次 heartbeat" - : "候选 " + (draft.optJSONArray("candidates") == null ? 0 : draft.optJSONArray("candidates").length()) + " · 状态 " + draft.optString("status", "-") + : "状态 " + resolveStatusTitle(draft) )); if (draft == null) { @@ -98,6 +100,22 @@ public class DeviceImportDraftActivity extends BossScreenActivity { return; } + int recommendedCount = 0; + for (int i = 0; i < candidates.length(); i++) { + JSONObject candidate = candidates.optJSONObject(i); + if (candidate != null && candidate.optBoolean("suggestedImport", false)) { + recommendedCount += 1; + } + } + appendContent(BossUi.buildCard( + this, + resolveStatusTitle(draft), + resolveStatusBody(draft, resolution), + "候选 " + candidates.length() + + " · 已选 " + selectedCandidateIds.size() + + " · 推荐 " + recommendedCount + )); + Map grouped = new LinkedHashMap<>(); for (int i = 0; i < candidates.length(); i++) { JSONObject candidate = candidates.optJSONObject(i); @@ -133,7 +151,9 @@ public class DeviceImportDraftActivity extends BossScreenActivity { candidate.optString("threadDisplayName", "未命名线程"), "最近活跃:" + candidate.optString("lastActiveAt", "-"), null, - selectedState ? "已选" : (candidate.optBoolean("suggestedImport", false) ? "推荐" : null), + selectedState + ? (candidate.optBoolean("suggestedImport", false) ? "已选 · 推荐导入" : "已选") + : (candidate.optBoolean("suggestedImport", false) ? "推荐导入" : null), v -> toggleSelection(candidateId) )); } @@ -163,6 +183,16 @@ public class DeviceImportDraftActivity extends BossScreenActivity { } } + JSONArray appliedProjectNames = draft.optJSONArray("appliedProjectNames"); + if (appliedProjectNames != null && appliedProjectNames.length() > 0) { + appendContent(BossUi.buildCard( + this, + "应用结果", + "已导入 " + appliedProjectNames.length() + " 个线程:" + joinNames(appliedProjectNames) + "。", + "这些线程现在会出现在会话首页。" + )); + } + Button reviewButton = BossUi.buildMiniActionButton(this, "生成导入建议", true); reviewButton.setEnabled(!selectedCandidateIds.isEmpty()); reviewButton.setOnClickListener(v -> reviewSelection()); @@ -177,6 +207,67 @@ public class DeviceImportDraftActivity extends BossScreenActivity { setRefreshing(false); } + private String resolveStatusTitle(@Nullable JSONObject draft) { + if (draft == null) { + return "等待导入草稿"; + } + String status = draft.optString("status", ""); + if ("pending_candidates".equals(status)) { + return "等待候选线程"; + } + if ("pending_selection".equals(status)) { + return "等待勾选"; + } + if ("pending_resolution".equals(status)) { + return "建议生成中"; + } + if ("resolved".equals(status)) { + return "建议已生成"; + } + if ("applied".equals(status)) { + return "已导入"; + } + return "导入草稿"; + } + + private String resolveStatusBody(@Nullable JSONObject draft, @Nullable JSONObject resolution) { + if (draft == null) { + return "先让设备完成首次 heartbeat 并上报候选线程,导入草稿就会出现在这里。"; + } + String status = draft.optString("status", ""); + if ("pending_candidates".equals(status)) { + return "设备已经就绪,等 heartbeat 带回线程候选后,就可以开始勾选。"; + } + if ("pending_selection".equals(status)) { + return "先勾选想导入的线程,再生成导入建议。"; + } + if ("pending_resolution".equals(status)) { + return "勾选已保存,接下来会生成导入建议。"; + } + if ("resolved".equals(status)) { + return resolution == null ? "可以先看建议,再点应用导入。" : resolution.optString("summary", "可以先看建议,再点应用导入。"); + } + if ("applied".equals(status)) { + JSONArray appliedProjectNames = draft.optJSONArray("appliedProjectNames"); + if (appliedProjectNames != null && appliedProjectNames.length() > 0) { + return "已导入 " + appliedProjectNames.length() + " 个线程:" + joinNames(appliedProjectNames) + "。"; + } + return "导入已完成,线程已经落到会话首页。"; + } + return "先勾选线程,再生成导入建议,最后应用导入。"; + } + + private String joinNames(JSONArray values) { + List names = new ArrayList<>(); + for (int i = 0; i < values.length(); i++) { + String value = values.optString(i, ""); + if (!value.isEmpty()) { + names.add(value); + } + } + return String.join("、", names); + } + private void toggleSelection(String candidateId) { if (candidateId == null || candidateId.isEmpty()) { return; diff --git a/android/app/src/main/java/com/hyzq/boss/OpenAiOnboardingActivity.java b/android/app/src/main/java/com/hyzq/boss/OpenAiOnboardingActivity.java index 70c7797..8ee1fdd 100644 --- a/android/app/src/main/java/com/hyzq/boss/OpenAiOnboardingActivity.java +++ b/android/app/src/main/java/com/hyzq/boss/OpenAiOnboardingActivity.java @@ -180,7 +180,8 @@ public class OpenAiOnboardingActivity extends BossScreenActivity { if (!response.ok()) throw new IllegalStateException(response.message()); runOnUiThread(() -> { setResult(RESULT_OK); - showPostLoginActions(); + setRefreshing(false); + showPostLoginActions(response.json); }); } catch (Exception error) { runOnUiThread(() -> { @@ -194,12 +195,34 @@ public class OpenAiOnboardingActivity extends BossScreenActivity { }); } - private void showPostLoginActions() { + private void showPostLoginActions(JSONObject responseJson) { + JSONObject activeIdentity = responseJson == null ? null : responseJson.optJSONObject("activeIdentity"); + StringBuilder message = new StringBuilder(); + if (activeIdentity != null) { + String statusLabel = activeIdentity.optString("statusLabel", ""); + String note = activeIdentity.optString("note", ""); + message.append("当前主控:") + .append(activeIdentity.optString("label", "OpenAI 平台账号")) + .append(" · ") + .append(activeIdentity.optString("displayName", "")) + .append('\n') + .append("状态:") + .append(statusLabel.isEmpty() ? "可用" : statusLabel); + if (!note.isEmpty()) { + message.append('\n').append(note); + } + } else { + message.append("OpenAI 平台账号已登录,并设为当前主控。"); + } + new AlertDialog.Builder(this) .setTitle("OpenAI 平台账号已登录") - .setMessage("已经设为当前主控。现在就可以直接测试主 Agent 对话。") - .setPositiveButton("测试主 Agent 对话", (dialog, which) -> openMasterAgentConversation()) - .setNegativeButton("稍后再说", (dialog, which) -> finish()) + .setMessage(message.toString() + "\n\n你现在可以直接测试主 Agent 对话,确认当前主控链路是否可用。") + .setPositiveButton("测试主 Agent 对话", (dialog, which) -> { + openMasterAgentConversation(); + finish(); + }) + .setNegativeButton("返回账号页", (dialog, which) -> finish()) .setOnDismissListener(dialog -> { if (!isFinishing()) { finish(); @@ -213,6 +236,5 @@ public class OpenAiOnboardingActivity extends BossScreenActivity { intent.putExtra(ProjectDetailActivity.EXTRA_PROJECT_ID, "master-agent"); intent.putExtra(ProjectDetailActivity.EXTRA_PROJECT_NAME, "主 Agent"); startActivity(intent); - finish(); } } diff --git a/android/app/src/test/java/com/hyzq/boss/AiAccountsActivityTest.java b/android/app/src/test/java/com/hyzq/boss/AiAccountsActivityTest.java index 8517671..fe295c8 100644 --- a/android/app/src/test/java/com/hyzq/boss/AiAccountsActivityTest.java +++ b/android/app/src/test/java/com/hyzq/boss/AiAccountsActivityTest.java @@ -1,9 +1,14 @@ package com.hyzq.boss; import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import android.content.Intent; import android.content.SharedPreferences; import android.os.Looper; +import android.view.View; +import android.view.ViewGroup; +import android.widget.TextView; import org.json.JSONObject; import org.junit.Test; @@ -11,6 +16,8 @@ import org.junit.runner.RunWith; import org.robolectric.Robolectric; import org.robolectric.RobolectricTestRunner; import org.robolectric.annotation.Config; +import org.robolectric.Shadows; +import org.robolectric.shadows.ShadowActivity; import org.robolectric.shadows.ShadowToast; import org.robolectric.util.ReflectionHelpers; @@ -96,6 +103,36 @@ public class AiAccountsActivityTest { assertEquals(1, activity.reloadCount); } + @Test + public void activeIdentityCardOffersMainAgentTestEntry() throws Exception { + TestAiAccountsActivity activity = Robolectric.buildActivity(TestAiAccountsActivity.class).setup().get(); + JSONObject activeIdentity = new JSONObject() + .put("accountId", "acc-1") + .put("label", "主 GPT") + .put("displayName", "OpenAI 平台账号") + .put("roleLabel", "主 GPT") + .put("providerLabel", "OpenAI API") + .put("statusLabel", "ready") + .put("note", "当前账号可直接生成主 Agent 回复。") + .put("canGenerate", true); + + View card = ReflectionHelpers.callInstanceMethod( + activity, + "buildActiveIdentityCard", + ReflectionHelpers.ClassParameter.from(JSONObject.class, activeIdentity) + ); + + View testButton = findClickableViewContainingText(card, "测试主 Agent 对话"); + assertNotNull(testButton); + testButton.performClick(); + + ShadowActivity shadowActivity = Shadows.shadowOf(activity); + Intent nextIntent = shadowActivity.getNextStartedActivity(); + assertNotNull(nextIntent); + assertEquals(ProjectDetailActivity.class.getName(), nextIntent.getComponent().getClassName()); + assertEquals("master-agent", nextIntent.getStringExtra(ProjectDetailActivity.EXTRA_PROJECT_ID)); + } + private static final class TestAiAccountsActivity extends AiAccountsActivity { private int reloadCount = 0; @@ -342,4 +379,43 @@ public class AiAccountsActivityTest { @Override public void unregisterOnSharedPreferenceChangeListener(OnSharedPreferenceChangeListener listener) {} } + + private static View findClickableViewContainingText(View root, String expectedText) { + if (root == null) { + return null; + } + if (root.isClickable() && viewTreeContainsText(root, expectedText)) { + return root; + } + if (!(root instanceof ViewGroup)) { + return null; + } + ViewGroup group = (ViewGroup) root; + for (int index = 0; index < group.getChildCount(); index += 1) { + View match = findClickableViewContainingText(group.getChildAt(index), expectedText); + if (match != null) { + return match; + } + } + return null; + } + + private static boolean viewTreeContainsText(View root, String expectedText) { + if (root instanceof TextView) { + CharSequence text = ((TextView) root).getText(); + if (text != null && text.toString().contains(expectedText)) { + return true; + } + } + if (!(root instanceof ViewGroup)) { + return false; + } + ViewGroup group = (ViewGroup) root; + for (int index = 0; index < group.getChildCount(); index += 1) { + if (viewTreeContainsText(group.getChildAt(index), expectedText)) { + return true; + } + } + return false; + } } diff --git a/android/app/src/test/java/com/hyzq/boss/DeviceImportDraftActivityTest.java b/android/app/src/test/java/com/hyzq/boss/DeviceImportDraftActivityTest.java new file mode 100644 index 0000000..a245346 --- /dev/null +++ b/android/app/src/test/java/com/hyzq/boss/DeviceImportDraftActivityTest.java @@ -0,0 +1,175 @@ +package com.hyzq.boss; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +import android.content.Intent; +import android.view.View; +import android.view.ViewGroup; +import android.widget.TextView; + +import org.json.JSONArray; +import org.json.JSONObject; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.Robolectric; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.annotation.Config; +import org.robolectric.util.ReflectionHelpers; + +@RunWith(RobolectricTestRunner.class) +@Config(sdk = 34) +public class DeviceImportDraftActivityTest { + @Test + public void renderCurrentStateShowsSelectionAndRecommendationCopy() throws Exception { + TestDeviceImportDraftActivity activity = Robolectric + .buildActivity( + TestDeviceImportDraftActivity.class, + new Intent() + .putExtra(DeviceImportDraftActivity.EXTRA_DEVICE_ID, "device-1") + .putExtra(DeviceImportDraftActivity.EXTRA_DEVICE_NAME, "Mac Studio") + ) + .setup() + .get(); + + ReflectionHelpers.callInstanceMethod( + activity, + "applyPayload", + ReflectionHelpers.ClassParameter.from(JSONObject.class, buildPendingDraft()), + ReflectionHelpers.ClassParameter.from(JSONObject.class, null) + ); + + View content = activity.findViewById(R.id.screen_content); + assertTrue(viewTreeContainsText(content, "等待勾选")); + assertTrue(viewTreeContainsText(content, "推荐导入")); + assertTrue(viewTreeContainsText(content, "生成导入建议")); + assertFalse(viewTreeContainsText(content, "应用结果")); + } + + @Test + public void renderCurrentStateShowsAppliedResultAndImportedNames() throws Exception { + TestDeviceImportDraftActivity activity = Robolectric + .buildActivity( + TestDeviceImportDraftActivity.class, + new Intent() + .putExtra(DeviceImportDraftActivity.EXTRA_DEVICE_ID, "device-1") + .putExtra(DeviceImportDraftActivity.EXTRA_DEVICE_NAME, "Mac Studio") + ) + .setup() + .get(); + + ReflectionHelpers.callInstanceMethod( + activity, + "applyPayload", + ReflectionHelpers.ClassParameter.from(JSONObject.class, buildAppliedDraft()), + ReflectionHelpers.ClassParameter.from(JSONObject.class, buildAppliedResolution()) + ); + + View content = activity.findViewById(R.id.screen_content); + assertTrue(viewTreeContainsText(content, "已导入")); + assertTrue(viewTreeContainsText(content, "应用结果")); + assertTrue(viewTreeContainsText(content, "北区试产线回归")); + assertTrue(viewTreeContainsText(content, "北区试产线审计")); + assertTrue(viewTreeContainsText(content, "已导入")); + } + + private static JSONObject buildPendingDraft() throws Exception { + return new JSONObject() + .put("draftId", "draft-1") + .put("deviceId", "device-1") + .put("status", "pending_selection") + .put("selectedCandidateIds", new JSONArray().put("candidate-1")) + .put("appliedProjectNames", new JSONArray()) + .put("candidates", new JSONArray() + .put(new JSONObject() + .put("candidateId", "candidate-1") + .put("deviceId", "device-1") + .put("folderName", "北区试产线") + .put("threadId", "thread-1") + .put("threadDisplayName", "北区试产线回归") + .put("lastActiveAt", "2026-03-30T10:18:00+08:00") + .put("suggestedImport", true)) + .put(new JSONObject() + .put("candidateId", "candidate-2") + .put("deviceId", "device-1") + .put("folderName", "北区试产线") + .put("threadId", "thread-2") + .put("threadDisplayName", "北区试产线审计") + .put("lastActiveAt", "2026-03-30T10:20:00+08:00") + .put("suggestedImport", false))); + } + + private static JSONObject buildAppliedDraft() throws Exception { + return new JSONObject() + .put("draftId", "draft-1") + .put("deviceId", "device-1") + .put("status", "applied") + .put("selectedCandidateIds", new JSONArray().put("candidate-1").put("candidate-2")) + .put("appliedProjectNames", new JSONArray().put("北区试产线回归").put("北区试产线审计")) + .put("candidates", new JSONArray() + .put(new JSONObject() + .put("candidateId", "candidate-1") + .put("deviceId", "device-1") + .put("folderName", "北区试产线") + .put("threadId", "thread-1") + .put("threadDisplayName", "北区试产线回归") + .put("lastActiveAt", "2026-03-30T10:18:00+08:00") + .put("suggestedImport", true)) + .put(new JSONObject() + .put("candidateId", "candidate-2") + .put("deviceId", "device-1") + .put("folderName", "北区试产线") + .put("threadId", "thread-2") + .put("threadDisplayName", "北区试产线审计") + .put("lastActiveAt", "2026-03-30T10:20:00+08:00") + .put("suggestedImport", true))); + } + + private static JSONObject buildAppliedResolution() throws Exception { + return new JSONObject() + .put("resolutionId", "resolution-1") + .put("draftId", "draft-1") + .put("deviceId", "device-1") + .put("status", "applied") + .put("summary", "Mac Studio 导入建议:新建 2 个会话。") + .put("items", new JSONArray() + .put(new JSONObject() + .put("candidateId", "candidate-1") + .put("action", "create_thread_conversation") + .put("threadDisplayName", "北区试产线回归") + .put("folderName", "北区试产线") + .put("reason", "作为独立聊天窗口导入。")) + .put(new JSONObject() + .put("candidateId", "candidate-2") + .put("action", "create_thread_conversation") + .put("threadDisplayName", "北区试产线审计") + .put("folderName", "北区试产线") + .put("reason", "作为独立聊天窗口导入。"))); + } + + private static boolean viewTreeContainsText(View root, String expectedText) { + if (root instanceof TextView) { + CharSequence text = ((TextView) root).getText(); + if (text != null && text.toString().contains(expectedText)) { + return true; + } + } + if (!(root instanceof ViewGroup)) { + return false; + } + ViewGroup group = (ViewGroup) root; + for (int index = 0; index < group.getChildCount(); index += 1) { + if (viewTreeContainsText(group.getChildAt(index), expectedText)) { + return true; + } + } + return false; + } + + public static class TestDeviceImportDraftActivity extends DeviceImportDraftActivity { + @Override + protected void reload() { + // Tests render synthetic payloads directly. + } + } +} diff --git a/android/app/src/test/java/com/hyzq/boss/OpenAiOnboardingActivityTest.java b/android/app/src/test/java/com/hyzq/boss/OpenAiOnboardingActivityTest.java index 99e608e..e4bcfc7 100644 --- a/android/app/src/test/java/com/hyzq/boss/OpenAiOnboardingActivityTest.java +++ b/android/app/src/test/java/com/hyzq/boss/OpenAiOnboardingActivityTest.java @@ -2,6 +2,7 @@ package com.hyzq.boss; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; import android.content.Intent; import android.net.Uri; @@ -12,6 +13,7 @@ import android.widget.TextView; import androidx.appcompat.app.AlertDialog; +import org.json.JSONObject; import org.junit.Test; import org.junit.runner.RunWith; import org.robolectric.Robolectric; @@ -63,16 +65,32 @@ public class OpenAiOnboardingActivityTest { } @Test - public void successActionsDialogCanOpenMasterAgentConversation() { + public void successActionsDialogCanOpenMasterAgentConversation() throws Exception { OpenAiOnboardingActivity activity = Robolectric .buildActivity(OpenAiOnboardingActivity.class) .setup() .get(); - ReflectionHelpers.callInstanceMethod(activity, "showPostLoginActions"); + JSONObject payload = new JSONObject(); + JSONObject activeIdentity = new JSONObject(); + activeIdentity.put("label", "主 GPT"); + activeIdentity.put("displayName", "OpenAI 平台账号"); + activeIdentity.put("statusLabel", "ready"); + activeIdentity.put("note", "当前账号可直接生成主 Agent 回复。"); + payload.put("activeIdentity", activeIdentity); + + ReflectionHelpers.callInstanceMethod( + activity, + "showPostLoginActions", + ReflectionHelpers.ClassParameter.from(JSONObject.class, payload) + ); AlertDialog dialog = (AlertDialog) ShadowDialog.getLatestDialog(); assertNotNull(dialog); + TextView messageView = dialog.findViewById(android.R.id.message); + assertNotNull(messageView); + assertTrue(messageView.getText().toString().contains("当前主控:主 GPT · OpenAI 平台账号")); + assertTrue(messageView.getText().toString().contains("你现在可以直接测试主 Agent 对话")); dialog.getButton(AlertDialog.BUTTON_POSITIVE).performClick(); Shadows.shadowOf(Looper.getMainLooper()).idle(); diff --git a/docs/architecture/api_and_service_inventory_cn.md b/docs/architecture/api_and_service_inventory_cn.md index 0d2b417..7549fa9 100644 --- a/docs/architecture/api_and_service_inventory_cn.md +++ b/docs/architecture/api_and_service_inventory_cn.md @@ -346,6 +346,7 @@ - 如本机节点未接通,可切到 `OpenAI API` 容灾账号 - 群聊项目当前会带上 `collaborationGate`,用于标明当前是否需要先经主 Agent / 用户审批 - 群聊文本消息当前还会返回 `dispatchPlan / dispatchRecommendation`,用于展示主 Agent 推荐的线程下发方案 + - 如果群里已经有一条待确认推荐,接口会直接返回 `409`,要求先确认或拒绝当前推荐,避免审批消息叠加 #### `GET /api/v1/projects/[projectId]/participants` @@ -446,6 +447,7 @@ - 校验成功后创建或更新 `openai_api` 主账号 - 立即设为当前主控 - 返回 `activeIdentity` + - 返回结果会带当前主控状态摘要,供原生 Android 直接弹出“测试主 Agent 对话” - 若服务器当前无法访问 `api.openai.com`,会直接返回明确中文网络错误,而不是只返回 `fetch failed` #### `POST /api/v1/accounts/onboard/master-node` @@ -632,6 +634,7 @@ - 当前行为: - 返回最新 `deviceImportDraft` - 如果已经做过导入决议,还会一并返回最新 `deviceImportResolution` + - 草稿里会带当前导入状态、推荐数量和最终已导入线程名,供 Web / Android 前台直接渲染状态卡 - 当前保护: - 仅 `highest_admin` 或设备所属账号可读 diff --git a/docs/architecture/current_runtime_and_deploy_status_cn.md b/docs/architecture/current_runtime_and_deploy_status_cn.md index 2143054..f587506 100644 --- a/docs/architecture/current_runtime_and_deploy_status_cn.md +++ b/docs/architecture/current_runtime_and_deploy_status_cn.md @@ -1,6 +1,6 @@ # Boss 当前运行与部署状态 -更新时间:`2026-03-30` +更新时间:`2026-03-31` ## 1. 本地状态 @@ -21,7 +21,7 @@ - 登录恢复接口:`POST http://127.0.0.1:3000/api/auth/restore` - 登出接口:`POST http://127.0.0.1:3000/api/auth/logout` - OTA 包下载接口:`GET http://127.0.0.1:3000/api/v1/user/ota/package` -- 本地 agent 健康检查:`http://127.0.0.1:4317/health` +- 本地 agent 健康检查:`http://127.0.0.1:4317/health`。当前这台开发机在本轮 `launchctl` 重载后仍未恢复,但代码已改成先启动本地 health 监听、再异步执行首次 heartbeat / task poll,剩余问题已收敛到 launchd 环境差异排查 - 本地 Skill 扫描接口:`http://127.0.0.1:4317/api/v1/skills` - 本地 agent 手动 heartbeat:`POST http://127.0.0.1:4317/api/v1/heartbeat` - `launchd` 已安装:`~/Library/LaunchAgents/com.hyzq.boss.local-agent.plist` @@ -104,14 +104,17 @@ cd /Users/kris/code/boss - 当前群聊编排主链已补上第一轮闭环:群聊文本消息会先进入主 Agent 生成推荐下发方案;用户确认后会创建真正的线程执行单,并写入系统通知;执行完成后会把线程原始结果镜像回群聊,再追加一条主 Agent 汇总 - 当前 `approval_required` 群聊已补齐“确认 / 拒绝”两条审批动作:确认后才会创建 `dispatchExecution`,拒绝后会把群审批状态写成 `rejected`,并在群里追加明确系统提示 - 当前原生聊天页已把待审批推荐前移到主消息流:`ProjectDetailActivity` 会直接显示 `确认下发 / 拒绝` 操作,且刷新后仍能恢复最近一条待确认推荐 +- 当前 `approval_required` 群聊在已经存在一条 `pending_user_confirmation` 推荐时,会拒绝继续创建新的推荐并返回 `409`,前台会提示用户先确认或拒绝当前推荐 - 当前普通单线程聊天也已补上真实执行链:`POST /api/v1/projects/[projectId]/messages` 不再只写用户消息,而是会追加 `conversation_reply` 任务;绑定设备上的 `local-agent` 认领后会继续恢复到真实 Codex 线程,再把线程原始回复回写到该聊天窗口 - 当前 Web 群聊详情页也已补上待确认推荐的刷新恢复:服务端会在页面渲染时读取最近一条 `pending_user_confirmation` 的 dispatch plan,聊天输入区会继续显示“等待你确认主 Agent 推荐”,不再因刷新丢失确认入口 - 当前 `AI 账号` 页面已分成两条显式接入链:`登录 OpenAI 平台账号(API Key)` 和 `绑定 Master Codex Node`;OpenAI API 登录成功后会立即切成当前主控 - 当前 `登录 OpenAI 平台账号` 已升级成浏览器辅助登录流:原生 Android 会先进入 `OpenAiOnboardingActivity`,自动打开 `OpenAI Platform` 登录页;用户登录后可直接跳到 `API Keys` 页面,回 APP 粘贴 key 完成接入 - 当前 `OpenAiOnboardingActivity` 在登录成功后会直接弹出 `测试主 Agent 对话`,可一键进入 `master-agent` 聊天页验证主控链路 +- 当前 `AI 账号` 页顶部会直接展示“当前主控身份”,并提供 `校验主控 / 测试主 Agent 对话` 两个入口,切换主控后不必再手动退回会话页验证 - 当前如果主控身份还是 `Master Codex Node`,但该节点离线或执行立即失败,主 Agent 会优先尝试已配置的 `OpenAI API` 备用账号,不再把失败日志直接原样回给用户 - 当前设备导入主链也已补上第一轮后端闭环:`heartbeat` 可上报真实项目候选,服务端会生成 `deviceImportDraft`;用户可提交勾选结果、生成导入决议,再把选中的线程真正落成聊天窗口 - Web 与原生 Android 当前都已补上“新设备导入草稿 -> 勾选 -> 决议预览 -> 应用导入”的前台流程;已绑定生产设备继续保留 heartbeat 自动导入主链 +- 当前设备导入前台的状态表达已经统一为:`等待候选线程 / 等待勾选 / 建议生成中 / 建议已生成 / 已导入`,并会回显最终导入的线程名 - 当前群资料页已补上“修复群成员”入口:当群里存在失效线程引用、`master-agent` 这类不可下发成员,或真实线程成员少于 2 个时,前台会明确提示并允许重新选择真实线程成员 - 当前原生聊天页也已前移“修复群成员”入口:脏群会在消息流上方直接显示 `去修复` 按钮,并跳转到群资料页完成成员替换 - 当前当 heartbeat 同时携带旧 `projects` 和新 `projectCandidates` 时,服务端会优先走 `deviceImportDraft`,不再绕过勾选/审核阶段直接自动导入聊天窗口 @@ -171,6 +174,7 @@ cd /Users/kris/code/boss - 当前 `local-agent` 对 `conversation_reply / dispatch_execution` 任务会优先使用 `codex exec resume `,只有缺失真实线程引用时才退回 `--ephemeral` - 当前历史脏群如果不再包含真实线程成员,群聊消息不会再表现成“无响应”;服务端会在群内追加明确 `system_notice`,提示先重新添加线程成员 - 当前设备导入决议已经会先落 `device_import_resolution` master task 再写回结果,但决议内容仍是服务端 heuristic 版;下一阶段可再升级成真正通过 `local-agent -> codex exec` 参与理解的主 Agent 决议 +- 当前 `local-agent` 已改成先启动本地 `4317` 健康监听,再异步跑首次 heartbeat 和 task poll,避免控制面短时阻塞时本地健康探针不可用 - 原生 Android 当前对 `master-agent` 聊天消息已单独放宽读超时到 `65s`;之前默认 `12s` 会把等待 `Master Codex Node / local-agent` 回写的长请求误判成“主 Agent 无响应” ## 2. 服务器状态 diff --git a/local-agent/server.mjs b/local-agent/server.mjs index e3ff263..4de7cf4 100755 --- a/local-agent/server.mjs +++ b/local-agent/server.mjs @@ -567,15 +567,6 @@ async function heartbeat() { } } -await heartbeat(); -await pollMasterAgentTasks(config, runtime); -setInterval(() => { - void heartbeat(); -}, config.heartbeatIntervalMs ?? 60000); -setInterval(() => { - void pollMasterAgentTasks(config, runtime); -}, config.masterAgentPollIntervalMs ?? 3000); - const server = createServer(async (request, response) => { if (request.url === "/health") { response.writeHead(200, { "Content-Type": "application/json" }); @@ -635,3 +626,16 @@ server.listen(config.port, config.bindHost, () => { }), ); }); + +void (async () => { + await heartbeat(); + await pollMasterAgentTasks(config, runtime); +})(); + +setInterval(() => { + void heartbeat(); +}, config.heartbeatIntervalMs ?? 60000); + +setInterval(() => { + void pollMasterAgentTasks(config, runtime); +}, config.masterAgentPollIntervalMs ?? 3000); diff --git a/src/app/api/v1/projects/[projectId]/messages/route.ts b/src/app/api/v1/projects/[projectId]/messages/route.ts index 0da0af0..3360b62 100644 --- a/src/app/api/v1/projects/[projectId]/messages/route.ts +++ b/src/app/api/v1/projects/[projectId]/messages/route.ts @@ -7,6 +7,26 @@ import { replyToMasterAgentUserMessage, } from "@/lib/boss-master-agent"; +function buildCollaborationGate(project?: { + isGroup: boolean; + collaborationMode: "development" | "approval_required"; + approvalState: "not_required" | "pending_agent" | "pending_user" | "approved" | "rejected"; +}) { + return project + ? { + isGroup: project.isGroup, + collaborationMode: project.collaborationMode, + requiresMasterAgentApproval: project.isGroup && project.collaborationMode === "approval_required", + approvalState: project.approvalState, + } + : { + isGroup: false, + collaborationMode: "development" as const, + requiresMasterAgentApproval: false, + approvalState: "not_required" as const, + }; +} + function dispatchFailureNotice(error?: string) { switch (error) { case "GROUP_DISPATCH_TARGETS_REQUIRED": @@ -33,6 +53,33 @@ export async function POST( }; try { + const state = await readState(); + const project = state.projects.find((item) => item.id === projectId); + const shouldCreateDispatchPlan = + project?.isGroup && + project.id !== "master-agent" && + (body.kind ?? "text") === "text" && + (body.body ?? "").trim().length > 0; + + if (shouldCreateDispatchPlan && project.collaborationMode === "approval_required") { + const pendingPlan = [...state.dispatchPlans] + .filter( + (plan) => plan.groupProjectId === projectId && plan.status === "pending_user_confirmation", + ) + .sort((left, right) => right.createdAt.localeCompare(left.createdAt))[0]; + if (pendingPlan) { + return NextResponse.json( + { + ok: false, + message: "当前还有一条主 Agent 推荐等待你确认,请先确认或拒绝后再继续发送新指令。", + pendingPlan, + collaborationGate: buildCollaborationGate(project), + }, + { status: 409 }, + ); + } + } + const message = await appendProjectMessage({ projectId, senderLabel: session.displayName || "你", @@ -66,14 +113,6 @@ export async function POST( } | null = null; - const state = await readState(); - const project = state.projects.find((item) => item.id === projectId); - const shouldCreateDispatchPlan = - project?.isGroup && - project.id !== "master-agent" && - (body.kind ?? "text") === "text" && - message.body.trim().length > 0; - if (shouldCreateDispatchPlan) { try { const recommendation = await queueGroupDispatchPlan({ @@ -146,20 +185,7 @@ export async function POST( const nextState = shouldCreateDispatchPlan ? await readState() : state; const nextProject = nextState.projects.find((item) => item.id === projectId); - const collaborationGate = nextProject - ? { - isGroup: nextProject.isGroup, - collaborationMode: nextProject.collaborationMode, - requiresMasterAgentApproval: - nextProject.isGroup && nextProject.collaborationMode === "approval_required", - approvalState: nextProject.approvalState, - } - : { - isGroup: false, - collaborationMode: "development" as const, - requiresMasterAgentApproval: false, - approvalState: "not_required" as const, - }; + const collaborationGate = buildCollaborationGate(nextProject); return NextResponse.json({ ok: true, diff --git a/src/components/device-import-draft-manager.tsx b/src/components/device-import-draft-manager.tsx index b748075..ba17aec 100644 --- a/src/components/device-import-draft-manager.tsx +++ b/src/components/device-import-draft-manager.tsx @@ -11,11 +11,27 @@ type ImportDraftResponse = { message?: string; }; +type FeedbackTone = "info" | "success" | "error"; + +type Feedback = { + tone: FeedbackTone; + text: string; +}; + +export type DeviceImportDraftViewCopy = { + statusTitle: string; + statusBody: string; + recommendationHint: string; + resultTitle: string; + resultBody: string; + candidateCount: number; + selectedCount: number; + recommendedCount: number; + appliedProjectNames: string[]; +}; + function groupCandidates(draft: DeviceImportDraft | null) { - const groups = new Map< - string, - Array - >(); + const groups = new Map>(); for (const candidate of draft?.candidates ?? []) { const key = candidate.codexFolderRef?.trim() || candidate.folderRef?.trim() || candidate.folderName; const bucket = groups.get(key) ?? []; @@ -29,6 +45,98 @@ function groupCandidates(draft: DeviceImportDraft | null) { })); } +function joinProjectNames(projectNames: string[]) { + return projectNames.length > 0 ? projectNames.join("、") : ""; +} + +export function describeDeviceImportDraft( + draft: DeviceImportDraft | null, + resolution: DeviceImportResolution | null, +): DeviceImportDraftViewCopy { + const candidateCount = draft?.candidates.length ?? 0; + const selectedCount = draft?.selectedCandidateIds.length ?? 0; + const recommendedCount = draft?.candidates.filter((candidate) => candidate.suggestedImport).length ?? 0; + const appliedProjectNames = draft?.appliedProjectNames ?? []; + const appliedProjectCount = appliedProjectNames.length; + + if (!draft) { + return { + statusTitle: "等待导入草稿", + statusBody: "先让设备完成首次 heartbeat 并上报候选线程,导入草稿就会出现在这里。", + recommendationHint: "拿到候选线程后,先从标记为推荐导入的项目开始。", + resultTitle: "导入结果", + resultBody: "生成导入建议并应用后,这里会显示真正导入到会话首页的线程。", + candidateCount, + selectedCount, + recommendedCount, + appliedProjectNames, + }; + } + + let statusTitle = "等待勾选"; + let statusBody = "先勾选要导入的线程,再生成导入建议。"; + let resultTitle = "导入建议"; + let resultBody = "应用导入前,这里会先显示主 Agent 风格的导入建议。"; + + switch (draft.status) { + case "pending_candidates": + statusTitle = "等待候选线程"; + statusBody = "设备已经就绪,等 heartbeat 带回线程候选后,就可以开始勾选。"; + resultTitle = "导入结果"; + resultBody = "候选线程出现后,这里会显示推荐和建议。"; + break; + case "pending_selection": + statusTitle = "等待勾选"; + statusBody = "先勾选想导入的线程,再生成导入建议。"; + break; + case "pending_resolution": + statusTitle = "建议生成中"; + statusBody = "勾选已保存,接下来会生成导入建议。"; + resultTitle = "导入建议"; + resultBody = "导入建议生成后,会先显示每个线程的处理方式和原因。"; + break; + case "resolved": + statusTitle = "建议已生成"; + statusBody = "可以先看建议,再点应用导入把线程落成会话窗口。"; + resultTitle = "导入建议"; + resultBody = resolution?.summary ?? "主 Agent 已给出导入建议。"; + break; + case "applied": + statusTitle = "已导入"; + statusBody = + appliedProjectCount > 0 + ? `已导入 ${appliedProjectCount} 个线程:${joinProjectNames(appliedProjectNames)}。` + : "导入已完成,线程已经落到会话首页。"; + resultTitle = "应用结果"; + resultBody = + appliedProjectCount > 0 + ? `已把 ${appliedProjectCount} 个线程导入到会话首页。` + : "应用导入后,线程已经出现在会话首页。"; + break; + default: + break; + } + + const recommendationHint = + recommendedCount > 0 + ? `推荐 ${recommendedCount} 项,优先勾选带“推荐导入”的线程。` + : candidateCount > 0 + ? "当前没有显式推荐项,按最近活跃度挑选也可以。" + : "当前还没有可选线程。"; + + return { + statusTitle, + statusBody, + recommendationHint, + resultTitle, + resultBody, + candidateCount, + selectedCount, + recommendedCount, + appliedProjectNames, + }; +} + export function DeviceImportDraftManager({ deviceId, deviceName, @@ -38,20 +146,32 @@ export function DeviceImportDraftManager({ }) { const router = useRouter(); const [loading, setLoading] = useState(false); - const [message, setMessage] = useState(""); + const [feedback, setFeedback] = useState(null); const [draft, setDraft] = useState(null); const [resolution, setResolution] = useState(null); const [selectedCandidateIds, setSelectedCandidateIds] = useState([]); const loadDraft = useCallback(async () => { setLoading(true); - const response = await fetch(`/api/v1/devices/${deviceId}/import-draft`, { cache: "no-store" }); - const data = (await response.json()) as ImportDraftResponse; - setLoading(false); - setDraft(data.draft ?? null); - setResolution(data.resolution ?? null); - setSelectedCandidateIds(data.draft?.selectedCandidateIds ?? []); - setMessage(data.ok ? "" : data.message ?? "导入草稿加载失败"); + try { + const response = await fetch(`/api/v1/devices/${deviceId}/import-draft`, { cache: "no-store" }); + const data = (await response.json()) as ImportDraftResponse; + setDraft(data.draft ?? null); + setResolution(data.resolution ?? null); + setSelectedCandidateIds(data.draft?.selectedCandidateIds ?? []); + setFeedback( + data.ok + ? null + : { tone: "error", text: data.message ?? "导入草稿加载失败" }, + ); + } catch (error) { + setFeedback({ + tone: "error", + text: error instanceof Error ? error.message : "导入草稿加载失败", + }); + } finally { + setLoading(false); + } }, [deviceId]); useEffect(() => { @@ -62,6 +182,7 @@ export function DeviceImportDraftManager({ }, [loadDraft]); const groups = useMemo(() => groupCandidates(draft), [draft]); + const copy = useMemo(() => describeDeviceImportDraft(draft, resolution), [draft, resolution]); function toggle(candidateId: string) { setSelectedCandidateIds((current) => @@ -73,67 +194,100 @@ export function DeviceImportDraftManager({ async function reviewSelection() { setLoading(true); - const selectResponse = await fetch(`/api/v1/devices/${deviceId}/import-draft/select`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ selectedCandidateIds }), - }); - const selectResult = (await selectResponse.json()) as { ok: boolean; message?: string; draft?: DeviceImportDraft }; - if (!selectResult.ok) { - setLoading(false); - setMessage(selectResult.message ?? "勾选保存失败"); - return; - } + try { + const selectResponse = await fetch(`/api/v1/devices/${deviceId}/import-draft/select`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ selectedCandidateIds }), + }); + const selectResult = (await selectResponse.json()) as { + ok: boolean; + message?: string; + draft?: DeviceImportDraft; + }; + if (!selectResult.ok) { + setFeedback({ tone: "error", text: selectResult.message ?? "勾选保存失败" }); + return; + } - const reviewResponse = await fetch(`/api/v1/devices/${deviceId}/import-draft/review`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({}), - }); - const reviewResult = (await reviewResponse.json()) as { - ok: boolean; - message?: string; - draft?: DeviceImportDraft; - resolution?: DeviceImportResolution; - }; - setLoading(false); - if (!reviewResult.ok) { - setMessage(reviewResult.message ?? "导入建议生成失败"); - return; + const reviewResponse = await fetch(`/api/v1/devices/${deviceId}/import-draft/review`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({}), + }); + const reviewResult = (await reviewResponse.json()) as { + ok: boolean; + message?: string; + draft?: DeviceImportDraft; + resolution?: DeviceImportResolution; + }; + if (!reviewResult.ok) { + setFeedback({ tone: "error", text: reviewResult.message ?? "导入建议生成失败" }); + return; + } + setDraft(reviewResult.draft ?? selectResult.draft ?? null); + setResolution(reviewResult.resolution ?? null); + setSelectedCandidateIds( + reviewResult.draft?.selectedCandidateIds ?? + selectResult.draft?.selectedCandidateIds ?? + selectedCandidateIds, + ); + setFeedback({ tone: "success", text: "已生成导入建议,先看推荐理由再应用导入。" }); + } catch (error) { + setFeedback({ + tone: "error", + text: error instanceof Error ? error.message : "导入建议生成失败", + }); + } finally { + setLoading(false); } - setDraft(reviewResult.draft ?? selectResult.draft ?? null); - setResolution(reviewResult.resolution ?? null); - setMessage("已生成导入建议。"); } async function applyResolution() { setLoading(true); - const response = await fetch(`/api/v1/devices/${deviceId}/import-draft/apply`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({}), - }); - const result = (await response.json()) as { - ok: boolean; - message?: string; - draft?: DeviceImportDraft; - resolution?: DeviceImportResolution; - }; - setLoading(false); - if (!result.ok) { - setMessage(result.message ?? "导入应用失败"); - return; + try { + const response = await fetch(`/api/v1/devices/${deviceId}/import-draft/apply`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({}), + }); + const result = (await response.json()) as { + ok: boolean; + message?: string; + draft?: DeviceImportDraft; + resolution?: DeviceImportResolution; + }; + if (!result.ok) { + setFeedback({ tone: "error", text: result.message ?? "导入应用失败" }); + return; + } + setDraft(result.draft ?? draft); + setResolution(result.resolution ?? resolution); + setSelectedCandidateIds(result.draft?.selectedCandidateIds ?? draft?.selectedCandidateIds ?? []); + setFeedback({ + tone: "success", + text: result.draft?.appliedProjectNames?.length + ? `已导入 ${result.draft.appliedProjectNames.length} 个线程:${joinProjectNames(result.draft.appliedProjectNames)}。` + : "已把选中的项目线程导入到会话首页。", + }); + router.refresh(); + } catch (error) { + setFeedback({ + tone: "error", + text: error instanceof Error ? error.message : "导入应用失败", + }); + } finally { + setLoading(false); } - setDraft(result.draft ?? draft); - setResolution(result.resolution ?? resolution); - setMessage("已把选中的项目线程导入到会话首页。"); - router.refresh(); } + const candidateCount = copy.candidateCount; + const recommendedCount = copy.recommendedCount; + return (
-
-
+
+
导入 Codex 项目
{deviceName ?? deviceId} 完成首次 heartbeat 后,这里会出现可导入项目和线程。 @@ -143,12 +297,21 @@ export function DeviceImportDraftManager({ type="button" onClick={() => void loadDraft()} disabled={loading} - className="rounded-full border border-[#D9D9D9] px-3 py-1 text-[12px] text-[#57606A]" + className="shrink-0 rounded-full border border-[#D9D9D9] px-3 py-1 text-[12px] text-[#57606A]" > {loading ? "刷新中" : "刷新"}
+
+
{copy.statusTitle}
+
{copy.statusBody}
+
+ 候选 {candidateCount} · 已选 {selectedCandidateIds.length} · 推荐 {recommendedCount} +
+
{copy.recommendationHint}
+
+ {draft ? (
候选线程:{draft.candidates.length} @@ -165,8 +328,17 @@ export function DeviceImportDraftManager({ {groups.map((group) => (
-
{group.folderName}
-
{group.items.length} 个线程
+
+
+
{group.folderName}
+
{group.items.length} 个线程
+
+ {group.items.some((candidate) => candidate.suggestedImport) ? ( + + 推荐导入 + + ) : null} +
{group.items.map((candidate) => { const selected = selectedCandidateIds.includes(candidate.candidateId); @@ -188,12 +360,19 @@ export function DeviceImportDraftManager({
最近活跃:{candidate.lastActiveAt}
+ {candidate.suggestedImport ? ( +
+ 这是推荐导入项 +
+ ) : null} +
+
+ {selected ? ( + + 已选 + + ) : null}
- {candidate.suggestedImport ? ( - - 推荐 - - ) : null} ); })} @@ -201,6 +380,11 @@ export function DeviceImportDraftManager({
))} +
+
{copy.resultTitle}
+
{copy.resultBody}
+
+ {resolution ? (
{resolution.summary}
@@ -214,6 +398,15 @@ export function DeviceImportDraftManager({
) : null} + {draft?.appliedProjectNames?.length ? ( +
+
已导入到会话首页
+
+ {draft.appliedProjectNames.length} 个线程:{joinProjectNames(draft.appliedProjectNames)} +
+
+ ) : null} +
- {message ? ( -
- {message} + {feedback ? ( +
+ {feedback.text}
) : null}
diff --git a/src/lib/boss-data.ts b/src/lib/boss-data.ts index f68b0b0..12c5d35 100644 --- a/src/lib/boss-data.ts +++ b/src/lib/boss-data.ts @@ -343,6 +343,7 @@ export interface DeviceImportDraft { status: "pending_candidates" | "pending_selection" | "pending_resolution" | "resolved" | "applied"; candidates: DeviceImportCandidate[]; selectedCandidateIds: string[]; + appliedProjectNames: string[]; createdAt: string; updatedAt: string; reviewedAt?: string; @@ -1899,6 +1900,9 @@ function normalizeDeviceImportDraft( selectedCandidateIds: dedupeStrings( ensureArray(raw.selectedCandidateIds, fallback?.selectedCandidateIds ?? []), ), + appliedProjectNames: dedupeStrings( + ensureArray(raw.appliedProjectNames, fallback?.appliedProjectNames ?? []), + ), createdAt: raw.createdAt ?? fallback?.createdAt ?? nowIso(), updatedAt: raw.updatedAt ?? fallback?.updatedAt ?? nowIso(), reviewedAt: raw.reviewedAt ?? fallback?.reviewedAt, @@ -5406,6 +5410,10 @@ function upsertDeviceImportDraftFromHeartbeat( : "pending_selection", candidates: payload.candidates, selectedCandidateIds, + appliedProjectNames: + existing?.status === "applied" && selectedCandidateIds.length > 0 + ? existing.appliedProjectNames + : [], createdAt: existing?.createdAt ?? nowIso(), updatedAt: nowIso(), reviewedAt: existing?.reviewedAt, @@ -5760,6 +5768,7 @@ function upsertDeviceImportResolutionInState( draft.reviewedAt = nowIso(); draft.reviewedBy = input.reviewedBy; draft.resolutionId = resolution.resolutionId; + draft.appliedProjectNames = []; state.deviceImportResolutions = [ resolution, @@ -5786,6 +5795,7 @@ export async function selectDeviceImportCandidates(input: { } draft.selectedCandidateIds = nextSelected; draft.status = "pending_resolution"; + draft.appliedProjectNames = []; draft.updatedAt = nowIso(); draft.reviewedBy = input.selectedBy; draft.reviewedAt = undefined; @@ -6074,6 +6084,7 @@ function applyDeviceImportResolutionInState( resolution.appliedAt = nowIso(); resolution.appliedBy = input.appliedBy; draft.status = "applied"; + draft.appliedProjectNames = importedProjects.map((project) => project.name); draft.updatedAt = nowIso(); return { diff --git a/tests/device-import-draft-manager.test.ts b/tests/device-import-draft-manager.test.ts new file mode 100644 index 0000000..693a1dc --- /dev/null +++ b/tests/device-import-draft-manager.test.ts @@ -0,0 +1,83 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { describeDeviceImportDraft } from "../src/components/device-import-draft-manager.tsx"; + +test("device import draft copy explains selection and recommendation state", () => { + const view = describeDeviceImportDraft( + { + draftId: "draft-1", + deviceId: "device-1", + status: "pending_selection", + candidates: [ + { + candidateId: "candidate-1", + deviceId: "device-1", + folderName: "北区试产线", + folderRef: "north-line", + threadId: "thread-1", + threadDisplayName: "北区试产线回归", + codexFolderRef: "north-line", + codexThreadRef: "thread-1", + lastActiveAt: "2026-03-30T10:18:00+08:00", + suggestedImport: true, + }, + { + candidateId: "candidate-2", + deviceId: "device-1", + folderName: "北区试产线", + folderRef: "north-line", + threadId: "thread-2", + threadDisplayName: "北区试产线审计", + codexFolderRef: "north-line", + codexThreadRef: "thread-2", + lastActiveAt: "2026-03-30T10:20:00+08:00", + suggestedImport: false, + }, + ], + selectedCandidateIds: ["candidate-1"], + appliedProjectNames: [], + createdAt: "2026-03-30T10:00:00+08:00", + updatedAt: "2026-03-30T10:20:00+08:00", + }, + null, + ); + + assert.equal(view.statusTitle, "等待勾选"); + assert.match(view.statusBody, /先勾选想导入的线程/); + assert.match(view.recommendationHint, /推荐 1 项/); + assert.equal(view.resultTitle, "导入建议"); + assert.match(view.resultBody, /应用导入前/); + assert.equal(view.selectedCount, 1); + assert.equal(view.recommendedCount, 1); +}); + +test("device import draft copy shows applied project names after import", () => { + const view = describeDeviceImportDraft( + { + draftId: "draft-1", + deviceId: "device-1", + status: "applied", + candidates: [], + selectedCandidateIds: [], + appliedProjectNames: ["北区试产线回归", "北区试产线审计"], + createdAt: "2026-03-30T10:00:00+08:00", + updatedAt: "2026-03-30T10:20:00+08:00", + }, + { + resolutionId: "resolution-1", + draftId: "draft-1", + deviceId: "device-1", + status: "applied", + summary: "MacBook Pro 导入建议:新建 2 个会话,关联 0 个现有会话。", + items: [], + createdAt: "2026-03-30T10:21:00+08:00", + appliedAt: "2026-03-30T10:22:00+08:00", + }, + ); + + assert.equal(view.statusTitle, "已导入"); + assert.match(view.statusBody, /已导入 2 个线程/); + assert.equal(view.resultTitle, "应用结果"); + assert.match(view.resultBody, /已把 2 个线程导入到会话首页/); + assert.deepEqual(view.appliedProjectNames, ["北区试产线回归", "北区试产线审计"]); +}); diff --git a/tests/device-import-draft.test.ts b/tests/device-import-draft.test.ts index e701700..2b42c5b 100644 --- a/tests/device-import-draft.test.ts +++ b/tests/device-import-draft.test.ts @@ -205,6 +205,11 @@ test("device import draft flow scans candidates, selects imports, resolves sugge { params: Promise.resolve({ deviceId: enrollmentPayload.device.id }) }, ); assert.equal(applyResponse.status, 200); + const applyPayload = (await applyResponse.json()) as { + importedProjects?: Array<{ id: string; name: string }>; + }; + assert.equal(applyPayload.importedProjects?.length, 1); + assert.equal(applyPayload.importedProjects?.[0]?.name, "北区试产线回归"); const nextState = await readState(); const importedProject = nextState.projects.find( @@ -224,6 +229,7 @@ test("device import draft flow scans candidates, selects imports, resolves sugge (resolution) => resolution.deviceId === enrollmentPayload.device.id, ); assert.equal(appliedDraft?.status, "applied"); + assert.deepEqual(appliedDraft?.appliedProjectNames, ["北区试产线回归"]); assert.equal(appliedResolution?.status, "applied"); }); diff --git a/tests/group-message-dispatch-plan.test.ts b/tests/group-message-dispatch-plan.test.ts index 9f6fe60..81d3b4d 100644 --- a/tests/group-message-dispatch-plan.test.ts +++ b/tests/group-message-dispatch-plan.test.ts @@ -280,6 +280,76 @@ test("POST /api/v1/projects/[projectId]/messages marks approval_required groups assert.match(pendingNotice?.body ?? "", /等待你确认|待审批|待确认/); }); +test("POST /api/v1/projects/[projectId]/messages blocks new approval_required requests while a plan is still pending", async () => { + await setup(); + const memberProjects = await ensureTwoSingleThreadProjects(); + assert.ok(memberProjects.length >= 2, "expected seeded single-thread projects"); + + const groupProject = await createIndependentGroupChat({ + memberProjectIds: [memberProjects[0].id, memberProjects[1].id], + createdBy: "17600003315", + }); + + const state = await readState(); + await writeState({ + ...state, + projects: state.projects.map((project) => + project.id === groupProject.id + ? { + ...project, + collaborationMode: "approval_required" as const, + approvalState: "not_required" as const, + } + : project, + ), + }); + + const firstResponse = await POST(await createAuthedRequest(groupProject.id, { body: "请协调两个线程确认上线方案" }), { + params: Promise.resolve({ projectId: groupProject.id }), + }); + assert.equal(firstResponse.status, 200); + const firstPayload = (await firstResponse.json()) as { + dispatchPlan: { planId: string } | null; + }; + assert.ok(firstPayload.dispatchPlan, "expected first message to create a dispatch plan"); + + const blockedResponse = await POST(await createAuthedRequest(groupProject.id, { body: "再补充一个新的下发要求" }), { + params: Promise.resolve({ projectId: groupProject.id }), + }); + assert.equal(blockedResponse.status, 409); + + const blockedPayload = (await blockedResponse.json()) as { + ok: boolean; + message: string; + pendingPlan: { planId: string } | null; + collaborationGate: { + approvalState: "not_required" | "pending_agent" | "pending_user" | "approved" | "rejected"; + requiresMasterAgentApproval: boolean; + }; + }; + + assert.equal(blockedPayload.ok, false); + assert.match(blockedPayload.message, /先确认|拒绝|待确认/); + assert.equal(blockedPayload.pendingPlan?.planId, firstPayload.dispatchPlan?.planId); + assert.equal(blockedPayload.collaborationGate.approvalState, "pending_user"); + assert.equal(blockedPayload.collaborationGate.requiresMasterAgentApproval, true); + + const nextState = await readState(); + const groupState = nextState.projects.find((project) => project.id === groupProject.id); + assert.ok(groupState, "expected group project to exist"); + assert.equal(groupState?.approvalState, "pending_user"); + assert.equal( + nextState.dispatchPlans.filter((plan) => plan.groupProjectId === groupProject.id && plan.status === "pending_user_confirmation").length, + 1, + "expected only the original pending dispatch plan to remain", + ); + assert.equal( + groupState?.messages.some((message) => message.body === "再补充一个新的下发要求"), + false, + "expected blocked request not to append a new user message", + ); +}); + test("POST /api/v1/projects/[projectId]/messages keeps message success when group dispatch recommendation fails", async () => { await setup(); const memberProjects = await ensureTwoSingleThreadProjects();