feat: harden agent onboarding and device import flows
This commit is contained in:
10
README.md
10
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 <targetCodexThreadRef>`,只有缺失真实线程引用时才退回 `--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`
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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 账号")
|
||||
|
||||
@@ -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
|
||||
);
|
||||
|
||||
@@ -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<String, JSONArray> 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<String> names = new ArrayList<>();
|
||||
for (int i = 0; i < values.length(); i++) {
|
||||
String value = values.optString(i, "");
|
||||
if (!value.isEmpty()) {
|
||||
names.add(value);
|
||||
}
|
||||
}
|
||||
return String.join("、", names);
|
||||
}
|
||||
|
||||
private void toggleSelection(String candidateId) {
|
||||
if (candidateId == null || candidateId.isEmpty()) {
|
||||
return;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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` 或设备所属账号可读
|
||||
|
||||
|
||||
@@ -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 <targetCodexThreadRef>`,只有缺失真实线程引用时才退回 `--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. 服务器状态
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<DeviceImportDraft["candidates"][number]>
|
||||
>();
|
||||
const groups = new Map<string, Array<DeviceImportDraft["candidates"][number]>>();
|
||||
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<Feedback | null>(null);
|
||||
const [draft, setDraft] = useState<DeviceImportDraft | null>(null);
|
||||
const [resolution, setResolution] = useState<DeviceImportResolution | null>(null);
|
||||
const [selectedCandidateIds, setSelectedCandidateIds] = useState<string[]>([]);
|
||||
|
||||
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 (
|
||||
<div className="space-y-3 rounded-2xl border border-[#E5E5EA] bg-white px-4 py-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="text-[16px] font-semibold text-[#111111]">导入 Codex 项目</div>
|
||||
<div className="mt-1 text-[12px] text-[#8C8C8C]">
|
||||
{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 ? "刷新中" : "刷新"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl bg-[#F7F8FA] px-4 py-3 text-[12px] leading-6 text-[#57606A]">
|
||||
<div className="font-semibold text-[#111111]">{copy.statusTitle}</div>
|
||||
<div className="mt-1 text-[#57606A]">{copy.statusBody}</div>
|
||||
<div className="mt-2 text-[#8C8C8C]">
|
||||
候选 {candidateCount} · 已选 {selectedCandidateIds.length} · 推荐 {recommendedCount}
|
||||
</div>
|
||||
<div className="mt-2 text-[#8C8C8C]">{copy.recommendationHint}</div>
|
||||
</div>
|
||||
|
||||
{draft ? (
|
||||
<div className="rounded-2xl bg-[#F7F8FA] px-4 py-3 text-[12px] leading-6 text-[#57606A]">
|
||||
候选线程:{draft.candidates.length}
|
||||
@@ -165,8 +328,17 @@ export function DeviceImportDraftManager({
|
||||
|
||||
{groups.map((group) => (
|
||||
<div key={group.key} className="rounded-2xl border border-[#EAECEF] bg-[#FCFCFD] px-3 py-3">
|
||||
<div className="text-[14px] font-semibold text-[#111111]">{group.folderName}</div>
|
||||
<div className="mt-1 text-[12px] text-[#8C8C8C]">{group.items.length} 个线程</div>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<div className="text-[14px] font-semibold text-[#111111]">{group.folderName}</div>
|
||||
<div className="mt-1 text-[12px] text-[#8C8C8C]">{group.items.length} 个线程</div>
|
||||
</div>
|
||||
{group.items.some((candidate) => candidate.suggestedImport) ? (
|
||||
<span className="shrink-0 rounded-full bg-[#EAF7F0] px-2 py-0.5 text-[10px] font-semibold text-[#215B39]">
|
||||
推荐导入
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="mt-3 space-y-2">
|
||||
{group.items.map((candidate) => {
|
||||
const selected = selectedCandidateIds.includes(candidate.candidateId);
|
||||
@@ -188,12 +360,19 @@ export function DeviceImportDraftManager({
|
||||
<div className="mt-1 text-[12px] text-[#8C8C8C]">
|
||||
最近活跃:{candidate.lastActiveAt}
|
||||
</div>
|
||||
{candidate.suggestedImport ? (
|
||||
<div className="mt-1 text-[11px] font-semibold text-[#215B39]">
|
||||
这是推荐导入项
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="shrink-0 text-right">
|
||||
{selected ? (
|
||||
<span className="rounded-full bg-[#EAF7F0] px-2 py-0.5 text-[10px] font-semibold text-[#215B39]">
|
||||
已选
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
{candidate.suggestedImport ? (
|
||||
<span className="rounded-full bg-[#EAF7F0] px-2 py-0.5 text-[10px] font-semibold text-[#215B39]">
|
||||
推荐
|
||||
</span>
|
||||
) : null}
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
@@ -201,6 +380,11 @@ export function DeviceImportDraftManager({
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div className="rounded-2xl bg-[#F7F8FA] px-4 py-3 text-[12px] leading-6 text-[#57606A]">
|
||||
<div className="font-semibold text-[#111111]">{copy.resultTitle}</div>
|
||||
<div className="mt-1 text-[#57606A]">{copy.resultBody}</div>
|
||||
</div>
|
||||
|
||||
{resolution ? (
|
||||
<div className="rounded-2xl bg-[#F7F8FA] px-4 py-3 text-[12px] leading-6 text-[#57606A]">
|
||||
<div className="font-semibold text-[#111111]">{resolution.summary}</div>
|
||||
@@ -214,6 +398,15 @@ export function DeviceImportDraftManager({
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{draft?.appliedProjectNames?.length ? (
|
||||
<div className="rounded-2xl border border-[#DCEFE5] bg-[#F3FBF6] px-4 py-3 text-[12px] leading-6 text-[#215B39]">
|
||||
<div className="font-semibold text-[#111111]">已导入到会话首页</div>
|
||||
<div className="mt-1">
|
||||
{draft.appliedProjectNames.length} 个线程:{joinProjectNames(draft.appliedProjectNames)}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
@@ -221,7 +414,7 @@ export function DeviceImportDraftManager({
|
||||
disabled={loading || selectedCandidateIds.length === 0}
|
||||
className="rounded-full bg-[#07C160] px-4 py-2 text-[13px] font-semibold text-white disabled:bg-[#B7E6C9]"
|
||||
>
|
||||
生成导入建议
|
||||
{draft?.status === "resolved" || draft?.status === "applied" ? "重新生成导入建议" : "生成导入建议"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -233,9 +426,17 @@ export function DeviceImportDraftManager({
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{message ? (
|
||||
<div className="rounded-2xl bg-[#EAF7F0] px-4 py-3 text-[12px] leading-6 text-[#215B39]">
|
||||
{message}
|
||||
{feedback ? (
|
||||
<div
|
||||
className={
|
||||
feedback.tone === "error"
|
||||
? "rounded-2xl bg-[#FDECEC] px-4 py-3 text-[12px] leading-6 text-[#9E1B1B]"
|
||||
: feedback.tone === "success"
|
||||
? "rounded-2xl bg-[#EAF7F0] px-4 py-3 text-[12px] leading-6 text-[#215B39]"
|
||||
: "rounded-2xl bg-[#F7F8FA] px-4 py-3 text-[12px] leading-6 text-[#57606A]"
|
||||
}
|
||||
>
|
||||
{feedback.text}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
83
tests/device-import-draft-manager.test.ts
Normal file
83
tests/device-import-draft-manager.test.ts
Normal file
@@ -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, ["北区试产线回归", "北区试产线审计"]);
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user