feat: streamline group dispatch reminders

This commit is contained in:
kris
2026-04-04 03:00:34 +08:00
parent 425d8992ef
commit 5ebb37cbfc
13 changed files with 485 additions and 37 deletions

View File

@@ -167,12 +167,18 @@ public class BossApiClient {
return requestWithRestore("DELETE", "/api/v1/projects/" + encode(projectId) + "/memories/" + encode(memoryId), null);
}
public ApiResponse confirmDispatchPlan(String projectId, String planId, JSONArray approvedTargetProjectIds) throws IOException, JSONException {
public ApiResponse confirmDispatchPlan(
String projectId,
String planId,
JSONArray approvedTargetProjectIds,
boolean rememberLightReminder
) throws IOException, JSONException {
JSONObject payload = new JSONObject();
payload.put(
"approvedTargetProjectIds",
approvedTargetProjectIds == null ? new JSONArray() : approvedTargetProjectIds
);
payload.put("rememberLightReminder", rememberLightReminder);
return requestWithRestoreRaw(
"POST",
"/api/v1/projects/" + encode(projectId) + "/dispatch-plans/" + encode(planId) + "/confirm",
@@ -182,6 +188,10 @@ public class BossApiClient {
);
}
public ApiResponse confirmDispatchPlan(String projectId, String planId, JSONArray approvedTargetProjectIds) throws IOException, JSONException {
return confirmDispatchPlan(projectId, planId, approvedTargetProjectIds, false);
}
public ApiResponse rejectDispatchPlan(String projectId, String planId) throws IOException, JSONException {
return requestWithRestoreRaw(
"POST",
@@ -227,6 +237,12 @@ public class BossApiClient {
return requestWithRestore("POST", "/api/v1/projects/" + encode(projectId) + "/participants", payload);
}
public ApiResponse updateProjectDispatchReminder(String projectId, boolean lightDispatchReminderEnabled) throws IOException, JSONException {
JSONObject payload = new JSONObject();
payload.put("lightDispatchReminderEnabled", lightDispatchReminderEnabled);
return requestWithRestore("PATCH", "/api/v1/projects/" + encode(projectId) + "/dispatch-reminder", payload);
}
public ApiResponse sendProjectMessage(String projectId, String body, String kind) throws IOException, JSONException {
JSONObject payload = new JSONObject();
payload.put("body", body);

View File

@@ -106,6 +106,7 @@ public class GroupInfoActivity extends BossScreenActivity {
if (orchestrationBackendPayload != null) {
appendContent(buildOrchestrationBackendRow(orchestrationBackendPayload));
}
appendContent(buildDispatchReminderRow(project));
if (repairRequired) {
String meta = invalidParticipantCount > 0
@@ -150,6 +151,18 @@ public class GroupInfoActivity extends BossScreenActivity {
setRefreshing(false);
}
private LinearLayout buildDispatchReminderRow(JSONObject project) {
boolean enabled = project.optBoolean("lightDispatchReminderEnabled", false);
return BossUi.buildWechatMenuRow(
this,
"推荐下发默认轻提醒",
enabled ? "已开启" : "已关闭",
enabled ? "后续推荐会保留轻状态卡,不再弹重确认提醒。" : "当前仍会显式提醒你确认主 Agent 推荐。",
enabled ? "开启" : "关闭",
v -> openDispatchReminderDialog(enabled)
);
}
private LinearLayout buildMemberRow(JSONObject participant) {
boolean sourceProject = participant.optBoolean("isSourceProject", false);
boolean canOpenProject = participant.optBoolean("canOpenProject", true);
@@ -426,6 +439,39 @@ public class GroupInfoActivity extends BossScreenActivity {
});
}
private void openDispatchReminderDialog(boolean enabled) {
CharSequence[] items = enabled
? new CharSequence[]{"关闭默认轻提醒"}
: new CharSequence[]{"开启默认轻提醒"};
new AlertDialog.Builder(this)
.setTitle("推荐下发默认轻提醒")
.setMessage(enabled
? "关闭后,这个群会恢复成每次都显式确认的提醒方式。"
: "开启后,这个群后续仍会显示一张轻状态卡,但不再出现重提醒。")
.setItems(items, (dialog, which) -> saveDispatchReminderPreference(!enabled))
.setNegativeButton("取消", null)
.show();
}
private void saveDispatchReminderPreference(boolean enabled) {
setRefreshing(true);
executor.execute(() -> {
try {
BossApiClient.ApiResponse response = apiClient.updateProjectDispatchReminder(projectId, enabled);
if (!response.ok()) throw new IllegalStateException(response.message());
runOnUiThread(() -> {
showMessage(enabled ? "已开启默认轻提醒" : "已关闭默认轻提醒");
reload();
});
} catch (Exception error) {
runOnUiThread(() -> {
setRefreshing(false);
showMessage("保存失败:" + error.getMessage());
});
}
});
}
private void showMoreMenu() {
new AlertDialog.Builder(this)
.setItems(new CharSequence[]{"改名", "刷新"}, (dialog, which) -> {

View File

@@ -312,6 +312,50 @@ public final class ProjectChatUiState {
return builder.toString();
}
public static String summarizeDispatchPlanCompact(@Nullable JSONObject plan) {
if (plan == null) {
return "主 Agent 暂未生成推荐线程。";
}
List<String> targetTitles = dispatchPlanTargetTitles(plan);
String summary = plan.optString("summary", "").trim();
if (targetTitles.isEmpty()) {
return isBlank(summary) ? "主 Agent 已生成推荐线程。" : truncate(summary, 32);
}
if (isBlank(summary)) {
return "推荐给:" + String.join("", targetTitles);
}
return "推荐给:" + String.join("", targetTitles) + "\n" + truncate(summary, 32);
}
public static String summarizeDispatchPlanLight(@Nullable JSONObject plan) {
int targetCount = dispatchPlanTargetTitles(plan).size();
if (targetCount <= 0) {
return "主 Agent 已推荐线程";
}
return "主 Agent 已推荐 " + targetCount + " 个线程";
}
private static List<String> dispatchPlanTargetTitles(@Nullable JSONObject plan) {
List<String> targetTitles = new ArrayList<>();
if (plan == null) {
return targetTitles;
}
JSONArray targets = plan.optJSONArray("targets");
if (targets != null) {
for (int i = 0; i < targets.length(); i++) {
JSONObject target = targets.optJSONObject(i);
if (target == null) {
continue;
}
String title = target.optString("threadDisplayName", "").trim();
if (!title.isEmpty()) {
targetTitles.add(title);
}
}
}
return targetTitles;
}
public static String formatAttachmentSize(long fileSizeBytes) {
if (fileSizeBytes >= 1024L * 1024L) {
return String.format(java.util.Locale.US, "%.1f MB", fileSizeBytes / (1024f * 1024f));

View File

@@ -70,6 +70,7 @@ public class ProjectDetailActivity extends BossScreenActivity {
private String currentScreenSubtitle;
private String projectCollaborationMode = "development";
private String projectApprovalState = "not_required";
private boolean lightDispatchReminderEnabled;
private @Nullable JSONObject currentPendingDispatchPlan;
private @Nullable JSONObject currentRejectedDispatchPlan;
private ProjectChatUiState.SelectionState selectionState = ProjectChatUiState.emptySelection();
@@ -310,6 +311,7 @@ public class ProjectDetailActivity extends BossScreenActivity {
projectFolderName = threadMeta == null ? "" : threadMeta.optString("folderName", "");
projectCollaborationMode = project == null ? "development" : project.optString("collaborationMode", "development");
projectApprovalState = project == null ? "not_required" : project.optString("approvalState", "not_required");
lightDispatchReminderEnabled = project != null && project.optBoolean("lightDispatchReminderEnabled", false);
JSONObject agentControls = project == null ? null : project.optJSONObject("agentControls");
currentAgentModelOverride = normalizeControlValue(agentControls == null ? null : agentControls.optString("modelOverride", null));
currentReasoningEffortOverride = normalizeControlValue(agentControls == null ? null : agentControls.optString("reasoningEffortOverride", null));
@@ -530,6 +532,8 @@ public class ProjectDetailActivity extends BossScreenActivity {
if (collaborationGate != null) {
projectCollaborationMode = collaborationGate.optString("collaborationMode", projectCollaborationMode);
projectApprovalState = collaborationGate.optString("approvalState", projectApprovalState);
lightDispatchReminderEnabled =
collaborationGate.optBoolean("lightDispatchReminderEnabled", lightDispatchReminderEnabled);
}
currentPendingDispatchPlan = dispatchPlan;
currentRejectedDispatchPlan = null;
@@ -542,7 +546,6 @@ public class ProjectDetailActivity extends BossScreenActivity {
: "消息已发送,主 Agent 已给出推荐线程。"
);
reload(true);
showDispatchPlanConfirmation(dispatchPlan);
return;
}
if (waitSpec.shouldWait) {
@@ -852,12 +855,28 @@ public class ProjectDetailActivity extends BossScreenActivity {
container.setOrientation(LinearLayout.VERTICAL);
container.addView(BossUi.buildCard(
this,
"approval_required".equals(projectCollaborationMode) ? "等待你批准主 Agent 下发" : "主 Agent 推荐下发",
ProjectChatUiState.summarizeDispatchPlan(dispatchPlan),
"当前确认状态:" + describeDispatchPlanApprovalState(projectApprovalState)
lightDispatchReminderEnabled
? ProjectChatUiState.summarizeDispatchPlanLight(dispatchPlan)
: ("approval_required".equals(projectCollaborationMode) ? "主 Agent 推荐下发" : "主 Agent 推荐"),
lightDispatchReminderEnabled
? ProjectChatUiState.summarizeDispatchPlanCompact(dispatchPlan)
: ProjectChatUiState.summarizeDispatchPlanCompact(dispatchPlan),
lightDispatchReminderEnabled ? "轻提醒已开启" : "当前状态:" + describeDispatchPlanApprovalState(projectApprovalState)
));
Button confirmButton = BossUi.buildMiniActionButton(this, "确认下发", true);
confirmButton.setOnClickListener(v -> showDispatchPlanConfirmation(dispatchPlan));
Button confirmButton = BossUi.buildMiniActionButton(
this,
lightDispatchReminderEnabled ? "继续下发" : "确认一下",
true
);
confirmButton.setOnClickListener(v -> confirmDispatchPlan(dispatchPlan, false));
if (!lightDispatchReminderEnabled) {
Button rememberButton = BossUi.buildMiniActionButton(this, "确认并记住", false);
rememberButton.setOnClickListener(v -> confirmDispatchPlan(dispatchPlan, true));
Button rejectButton = BossUi.buildMiniActionButton(this, "拒绝", false);
rejectButton.setOnClickListener(v -> rejectDispatchPlan(dispatchPlan));
container.addView(BossUi.buildInlineActionRow(this, confirmButton, rememberButton, rejectButton));
return container;
}
Button rejectButton = BossUi.buildMiniActionButton(this, "拒绝", false);
rejectButton.setOnClickListener(v -> rejectDispatchPlan(dispatchPlan));
container.addView(BossUi.buildInlineActionRow(this, confirmButton, rejectButton));
@@ -899,22 +918,7 @@ public class ProjectDetailActivity extends BossScreenActivity {
return container;
}
private void showDispatchPlanConfirmation(JSONObject dispatchPlan) {
String title = "approval_required".equals(projectCollaborationMode)
? "批准主 Agent 下发"
: "确认主 Agent 推荐";
String message = ProjectChatUiState.summarizeDispatchPlan(dispatchPlan)
+ "\n\n确认后会把任务下发到推荐线程并把线程原始回复与主 Agent 汇总一起回到群聊。";
new AlertDialog.Builder(this)
.setTitle(title)
.setMessage(message)
.setNegativeButton("拒绝", (dialog, which) -> rejectDispatchPlan(dispatchPlan))
.setNeutralButton("稍后", null)
.setPositiveButton("确认下发", (dialog, which) -> confirmDispatchPlan(dispatchPlan))
.show();
}
private void confirmDispatchPlan(JSONObject dispatchPlan) {
private void confirmDispatchPlan(JSONObject dispatchPlan, boolean rememberLightReminder) {
String planId = dispatchPlan.optString("planId", "").trim();
if (planId.isEmpty()) {
showMessage("缺少调度方案 ID");
@@ -932,7 +936,12 @@ public class ProjectDetailActivity extends BossScreenActivity {
for (String approvedTargetProjectId : approvedTargetProjectIds) {
approved.put(approvedTargetProjectId);
}
BossApiClient.ApiResponse response = apiClient.confirmDispatchPlan(projectId, planId, approved);
BossApiClient.ApiResponse response = apiClient.confirmDispatchPlan(
projectId,
planId,
approved,
rememberLightReminder
);
if (!response.ok()) {
throw new IllegalStateException(response.message());
}
@@ -942,6 +951,9 @@ public class ProjectDetailActivity extends BossScreenActivity {
ProjectChatUiState.resolveReplyWaitAfterDispatchConfirm(response.json);
runOnUiThread(() -> {
applyDispatchPlanActionResponse(response.json);
if (rememberLightReminder) {
showMessage("已确认下发,并对这个群开启默认轻提醒");
}
if (waitSpec.shouldWait) {
startReplyWait(
waitSpec,
@@ -1011,9 +1023,6 @@ public class ProjectDetailActivity extends BossScreenActivity {
applyDispatchPlanActionResponse(response.json);
showMessage("主 Agent 已重新生成推荐");
reload(true);
if (nextPlan != null) {
showDispatchPlanConfirmation(nextPlan);
}
});
} catch (Exception error) {
runOnUiThread(() -> {
@@ -2103,6 +2112,8 @@ public class ProjectDetailActivity extends BossScreenActivity {
if (collaborationGate != null) {
projectCollaborationMode = collaborationGate.optString("collaborationMode", projectCollaborationMode);
projectApprovalState = collaborationGate.optString("approvalState", projectApprovalState);
lightDispatchReminderEnabled =
collaborationGate.optBoolean("lightDispatchReminderEnabled", lightDispatchReminderEnabled);
}
JSONObject plan = response.optJSONObject("plan");
if (plan != null) {

View File

@@ -161,6 +161,29 @@ public class GroupInfoActivityTest {
assertTrue(viewTreeContainsText(content, "OMX Team Runtime 当前可用,当前可切换到该后端。"));
}
@Test
public void renderGroupShowsLightReminderPreferenceRow() throws Exception {
Intent intent = new Intent()
.putExtra(GroupInfoActivity.EXTRA_PROJECT_ID, "group-1")
.putExtra(GroupInfoActivity.EXTRA_PROJECT_NAME, "巡检协作群");
TestGroupInfoActivity activity = Robolectric
.buildActivity(TestGroupInfoActivity.class, intent)
.setup()
.get();
ReflectionHelpers.callInstanceMethod(
activity,
"renderGroup",
ReflectionHelpers.ClassParameter.from(JSONObject.class, buildDetailPayload(true)),
ReflectionHelpers.ClassParameter.from(JSONObject.class, buildParticipantsPayload()),
ReflectionHelpers.ClassParameter.from(JSONObject.class, buildOrchestrationBackendPayload())
);
LinearLayout content = activity.findViewById(R.id.screen_content);
assertTrue(viewTreeContainsText(content, "推荐下发默认轻提醒"));
assertTrue(viewTreeContainsText(content, "已开启"));
}
@Test
public void renderGroupShowsOmxFallbackHintWhenOmxRuntimeIsUnavailable() throws Exception {
Intent intent = new Intent()
@@ -236,7 +259,38 @@ public class GroupInfoActivityTest {
assertEquals("{\"requestedBackendId\":\"omx-team\"}", connection.requestBody());
}
@Test
public void saveDispatchReminderPreferenceUsesScopedEndpoint() throws Exception {
Intent intent = new Intent()
.putExtra(GroupInfoActivity.EXTRA_PROJECT_ID, "group-1")
.putExtra(GroupInfoActivity.EXTRA_PROJECT_NAME, "巡检协作群");
TestGroupInfoActivity activity = Robolectric
.buildActivity(TestGroupInfoActivity.class, intent)
.setup()
.get();
RecordingConnection connection = new RecordingConnection(
new URL("https://boss.hyzq.net/api/v1/projects/group-1/dispatch-reminder")
);
ReflectionHelpers.setField(activity, "apiClient", new RecordingBossApiClient(connection));
ReflectionHelpers.setField(activity, "executor", new DirectExecutorService());
ReflectionHelpers.callInstanceMethod(
activity,
"saveDispatchReminderPreference",
ReflectionHelpers.ClassParameter.from(boolean.class, true)
);
Shadows.shadowOf(Looper.getMainLooper()).idle();
assertEquals("/api/v1/projects/group-1/dispatch-reminder", connection.lastPath);
assertEquals("PATCH", connection.requestMethodValue);
assertEquals("{\"lightDispatchReminderEnabled\":true}", connection.requestBody());
}
private static JSONObject buildDetailPayload() throws Exception {
return buildDetailPayload(false);
}
private static JSONObject buildDetailPayload(boolean lightReminderEnabled) throws Exception {
JSONObject threadMeta = new JSONObject()
.put("threadId", "group-thread-3")
.put("folderName", "Boss");
@@ -245,6 +299,7 @@ public class GroupInfoActivityTest {
.put("name", "巡检协作群")
.put("isGroup", true)
.put("collaborationMode", "development")
.put("lightDispatchReminderEnabled", lightReminderEnabled)
.put("threadMeta", threadMeta);
return new JSONObject().put("project", project);
}

View File

@@ -463,10 +463,45 @@ public class ProjectDetailActivityUiTest {
ReflectionHelpers.ClassParameter.from(JSONObject.class, dispatchPlan)
);
assertTrue(viewTreeContainsText(card, "确认下"));
assertTrue(viewTreeContainsText(card, "确认"));
assertTrue(viewTreeContainsText(card, "确认并记住"));
assertTrue(viewTreeContainsText(card, "拒绝"));
}
@Test
public void pendingDispatchPlanViewUsesCompactCopyWhenLightReminderEnabled() throws Exception {
Intent intent = new Intent()
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_ID, "project-group")
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_NAME, "协作群");
TestProjectDetailActivity activity = Robolectric
.buildActivity(TestProjectDetailActivity.class, intent)
.setup()
.get();
ReflectionHelpers.setField(activity, "projectCollaborationMode", "approval_required");
ReflectionHelpers.setField(activity, "projectApprovalState", "pending_user");
ReflectionHelpers.setField(activity, "lightDispatchReminderEnabled", true);
JSONObject dispatchPlan = new JSONObject()
.put("planId", "dispatch-plan-light")
.put("summary", "建议先让 Boss 移动控制台同步树莓派二代状态。")
.put("targets", new JSONArray().put(new JSONObject()
.put("projectId", "thread-1")
.put("threadDisplayName", "Boss 移动控制台")
.put("reason", "最近活跃")));
View card = ReflectionHelpers.callInstanceMethod(
activity,
"buildPendingDispatchPlanView",
ReflectionHelpers.ClassParameter.from(JSONObject.class, dispatchPlan)
);
assertTrue(viewTreeContainsText(card, "主 Agent 已推荐 1 个线程"));
assertTrue(viewTreeContainsText(card, "继续下发"));
assertFalse(viewTreeContainsText(card, "等待你批准主 Agent 下发"));
assertFalse(viewTreeContainsText(card, "当前确认状态:"));
}
@Test
public void renderProjectShowsRepairEntryForDirtyGroupAndOpensGroupInfo() throws Exception {
Intent intent = new Intent()