feat: add dispatch retry and import recovery flows

This commit is contained in:
kris
2026-03-31 22:10:03 +08:00
parent be31503d22
commit dcbff3cc7d
15 changed files with 776 additions and 23 deletions

View File

@@ -137,6 +137,16 @@ public class BossApiClient {
);
}
public ApiResponse retryDispatchPlan(String projectId, String planId) throws IOException, JSONException {
return requestWithRestoreRaw(
"POST",
"/api/v1/projects/" + encode(projectId) + "/dispatch-plans/" + encode(planId) + "/retry",
new JSONObject().toString(),
DEFAULT_CONNECT_TIMEOUT_MS,
CHAT_FLOW_READ_TIMEOUT_MS
);
}
public ApiResponse renameConversation(String projectId, String name, boolean group) throws IOException, JSONException {
JSONObject payload = new JSONObject();
payload.put("name", name);

View File

@@ -95,7 +95,7 @@ public class DeviceImportDraftActivity extends BossScreenActivity {
JSONArray candidates = draft.optJSONArray("candidates");
if (candidates == null || candidates.length() == 0) {
appendContent(BossUi.buildEmptyCard(this, "当前还没有可导入线程。"));
appendContent(BossUi.buildEmptyCard(this, "设备已在线,但当前还没有发现可导入线程。可以稍后刷新重试。"));
setRefreshing(false);
return;
}
@@ -196,14 +196,17 @@ public class DeviceImportDraftActivity extends BossScreenActivity {
Button reviewButton = BossUi.buildMiniActionButton(this, "生成导入建议", true);
reviewButton.setEnabled(!selectedCandidateIds.isEmpty());
reviewButton.setOnClickListener(v -> reviewSelection());
Button clearButton = BossUi.buildMiniActionButton(this, "清空勾选", false);
clearButton.setEnabled(!selectedCandidateIds.isEmpty());
clearButton.setOnClickListener(v -> clearSelection());
Button applyButton = BossUi.buildMiniActionButton(
this,
"applied".equals(draft.optString("status", "")) ? "已导入" : "应用导入",
false
);
applyButton.setEnabled(resolution != null && !"applied".equals(draft.optString("status", "")));
applyButton.setEnabled(resolution != null && "resolved".equals(draft.optString("status", "")));
applyButton.setOnClickListener(v -> applyResolution());
appendContent(BossUi.buildInlineActionRow(this, reviewButton, applyButton));
appendContent(BossUi.buildInlineActionRow(this, reviewButton, clearButton, applyButton));
setRefreshing(false);
}
@@ -287,6 +290,7 @@ public class DeviceImportDraftActivity extends BossScreenActivity {
}
setRefreshing(true);
executor.execute(() -> {
JSONObject selectedDraft = null;
try {
JSONArray selected = new JSONArray();
for (String candidateId : selectedCandidateIds) {
@@ -296,6 +300,7 @@ public class DeviceImportDraftActivity extends BossScreenActivity {
if (!selectResponse.ok()) {
throw new IllegalStateException(selectResponse.message());
}
selectedDraft = selectResponse.json.optJSONObject("draft");
BossApiClient.ApiResponse reviewResponse = apiClient.reviewDeviceImportDraft(deviceId);
if (!reviewResponse.ok()) {
throw new IllegalStateException(reviewResponse.message());
@@ -304,10 +309,40 @@ public class DeviceImportDraftActivity extends BossScreenActivity {
showMessage("已生成导入建议");
applyPayload(reviewResponse.json.optJSONObject("draft"), reviewResponse.json.optJSONObject("resolution"));
});
} catch (Exception error) {
final JSONObject fallbackDraft = selectedDraft;
runOnUiThread(() -> {
if (fallbackDraft != null) {
applyPayload(fallbackDraft, null);
} else {
setRefreshing(false);
}
showMessage("导入建议生成失败:" + error.getMessage());
});
}
});
}
private void clearSelection() {
if (deviceId == null || deviceId.isEmpty()) {
showMessage("缺少 deviceId");
return;
}
setRefreshing(true);
executor.execute(() -> {
try {
BossApiClient.ApiResponse response = apiClient.selectDeviceImportCandidates(deviceId, new JSONArray());
if (!response.ok()) {
throw new IllegalStateException(response.message());
}
runOnUiThread(() -> {
showMessage("已清空当前勾选");
applyPayload(response.json.optJSONObject("draft"), null);
});
} catch (Exception error) {
runOnUiThread(() -> {
setRefreshing(false);
showMessage("导入建议生成失败:" + error.getMessage());
showMessage("清空勾选失败:" + error.getMessage());
});
}
});

View File

@@ -245,6 +245,23 @@ public final class ProjectChatUiState {
return null;
}
@Nullable
public static JSONObject latestRejectedDispatchPlan(@Nullable JSONArray plans) {
if (plans == null || plans.length() == 0) {
return null;
}
for (int i = 0; i < plans.length(); i++) {
JSONObject plan = plans.optJSONObject(i);
if (plan == null) {
continue;
}
if ("rejected".equals(plan.optString("status", ""))) {
return plan;
}
}
return null;
}
public static List<String> dispatchPlanApprovedTargetIds(@Nullable JSONObject plan) {
ArrayList<String> approved = new ArrayList<>();
if (plan == null) {

View File

@@ -70,6 +70,7 @@ public class ProjectDetailActivity extends BossScreenActivity {
private String projectCollaborationMode = "development";
private String projectApprovalState = "not_required";
private @Nullable JSONObject currentPendingDispatchPlan;
private @Nullable JSONObject currentRejectedDispatchPlan;
private ProjectChatUiState.SelectionState selectionState = ProjectChatUiState.emptySelection();
private ActivityResultLauncher<Intent> conversationInfoLauncher;
private ActivityResultLauncher<Intent> forwardTargetLauncher;
@@ -294,6 +295,9 @@ public class ProjectDetailActivity extends BossScreenActivity {
currentAgentModelOverride = normalizeControlValue(agentControls == null ? null : agentControls.optString("modelOverride", null));
currentReasoningEffortOverride = normalizeControlValue(agentControls == null ? null : agentControls.optString("reasoningEffortOverride", null));
currentPendingDispatchPlan = ProjectChatUiState.latestPendingDispatchPlan(dispatchPlans);
currentRejectedDispatchPlan = currentPendingDispatchPlan == null
? ProjectChatUiState.latestRejectedDispatchPlan(dispatchPlans)
: null;
conversationInfoReady = project != null;
updateProjectHeader(title, buildProjectSubtitle(projectFolderName, devices));
@@ -302,6 +306,8 @@ public class ProjectDetailActivity extends BossScreenActivity {
pendingOutgoingBubble = null;
if (currentPendingDispatchPlan != null) {
appendContent(buildPendingDispatchPlanView(currentPendingDispatchPlan));
} else if (projectIsGroup && "rejected".equals(projectApprovalState) && currentRejectedDispatchPlan != null) {
appendContent(buildRejectedDispatchPlanView(currentRejectedDispatchPlan));
}
if (projectIsGroup && participantsPayload != null && participantsPayload.optBoolean("repairRequired", false)) {
appendContent(buildRepairGroupMembersView(participantsPayload));
@@ -505,6 +511,7 @@ public class ProjectDetailActivity extends BossScreenActivity {
projectApprovalState = collaborationGate.optString("approvalState", projectApprovalState);
}
currentPendingDispatchPlan = dispatchPlan;
currentRejectedDispatchPlan = null;
if (dispatchPlan != null) {
composerSending = false;
updateComposerSendButtonState();
@@ -790,6 +797,21 @@ public class ProjectDetailActivity extends BossScreenActivity {
return container;
}
private View buildRejectedDispatchPlanView(JSONObject dispatchPlan) {
LinearLayout container = new LinearLayout(this);
container.setOrientation(LinearLayout.VERTICAL);
container.addView(BossUi.buildCard(
this,
"上次推荐已拒绝",
ProjectChatUiState.summarizeDispatchPlan(dispatchPlan),
"如果还想继续当前协作,可以重新生成推荐。"
));
Button retryButton = BossUi.buildMiniActionButton(this, "重新生成推荐", true);
retryButton.setOnClickListener(v -> retryDispatchPlan(dispatchPlan));
container.addView(BossUi.buildInlineActionRow(this, retryButton));
return container;
}
private View buildRepairGroupMembersView(JSONObject participantsPayload) {
String repairReason = participantsPayload.optString("repairReason", "当前群聊里有失效线程,请先修复群成员。");
int invalidParticipantCount = participantsPayload.optInt("invalidParticipantCount", 0);
@@ -902,6 +924,39 @@ public class ProjectDetailActivity extends BossScreenActivity {
});
}
private void retryDispatchPlan(JSONObject dispatchPlan) {
String planId = dispatchPlan.optString("planId", "").trim();
if (planId.isEmpty()) {
showMessage("缺少调度方案 ID");
return;
}
setRefreshing(true);
executor.execute(() -> {
try {
BossApiClient.ApiResponse response = apiClient.retryDispatchPlan(projectId, planId);
if (!response.ok()) {
throw new IllegalStateException(response.message());
}
JSONObject nextPlan = response.json.optJSONObject("dispatchPlan");
runOnUiThread(() -> {
currentRejectedDispatchPlan = null;
currentPendingDispatchPlan = nextPlan;
applyDispatchPlanActionResponse(response.json);
showMessage("主 Agent 已重新生成推荐");
reload(true);
if (nextPlan != null) {
showDispatchPlanConfirmation(nextPlan);
}
});
} catch (Exception error) {
runOnUiThread(() -> {
setRefreshing(false);
showMessage("重新生成推荐失败:" + error.getMessage());
});
}
});
}
private View buildMessageView(JSONObject message) {
String messageId = message.optString("id", "");
String sender = message.optString("sender", "");
@@ -1946,8 +2001,14 @@ public class ProjectDetailActivity extends BossScreenActivity {
JSONObject plan = response.optJSONObject("plan");
if (plan != null) {
String status = plan.optString("status", "");
if (!"pending_user_confirmation".equals(status)) {
if ("pending_user_confirmation".equals(status)) {
currentPendingDispatchPlan = plan;
currentRejectedDispatchPlan = null;
} else {
currentPendingDispatchPlan = null;
if ("rejected".equals(status)) {
currentRejectedDispatchPlan = plan;
}
}
}
}

View File

@@ -69,6 +69,21 @@ public class BossApiClientDispatchPlansTest {
assertEquals("{}", connection.requestBody());
}
@Test
public void retryDispatchPlanUsesProjectScopedRetryEndpoint() throws Exception {
RecordingConnection connection = new RecordingConnection(new URL("https://boss.hyzq.net/api/v1/projects/p1/dispatch-plans/plan-1/retry"));
RecordingBossApiClient apiClient = new RecordingBossApiClient(connection);
BossApiClient.ApiResponse response = apiClient.retryDispatchPlan("p1", "plan-1");
assertEquals(200, response.statusCode);
assertEquals("/api/v1/projects/p1/dispatch-plans/plan-1/retry", apiClient.lastPath);
assertEquals("POST", connection.requestMethodValue);
assertEquals(12000, connection.connectTimeoutValue);
assertEquals(65000, connection.readTimeoutValue);
assertEquals("{}", connection.requestBody());
}
@Test
public void getProjectAgentControlsUsesScopedEndpoint() throws Exception {
RecordingConnection connection = new RecordingConnection(new URL("https://boss.hyzq.net/api/v1/projects/master-agent/agent-controls"));

View File

@@ -392,6 +392,47 @@ public class ProjectDetailActivityUiTest {
assertEquals(null, ReflectionHelpers.getField(activity, "currentPendingDispatchPlan"));
}
@Test
public void applyDispatchPlanActionResponseStoresRejectedPlanForRecovery() throws Exception {
Intent intent = new Intent()
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_ID, "group-1")
.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,
"currentPendingDispatchPlan",
new JSONObject().put("planId", "dispatch-plan-1").put("status", "pending_user_confirmation")
);
JSONObject rejectedPlan = new JSONObject()
.put("planId", "dispatch-plan-1")
.put("status", "rejected");
JSONObject response = new JSONObject()
.put("plan", rejectedPlan)
.put("collaborationGate", new JSONObject()
.put("isGroup", true)
.put("collaborationMode", "approval_required")
.put("requiresMasterAgentApproval", true)
.put("approvalState", "rejected"));
ReflectionHelpers.callInstanceMethod(
activity,
"applyDispatchPlanActionResponse",
ReflectionHelpers.ClassParameter.from(JSONObject.class, response)
);
assertEquals("rejected", ReflectionHelpers.getField(activity, "projectApprovalState"));
assertEquals(null, ReflectionHelpers.getField(activity, "currentPendingDispatchPlan"));
JSONObject storedRejected = ReflectionHelpers.getField(activity, "currentRejectedDispatchPlan");
assertEquals("dispatch-plan-1", storedRejected.optString("planId"));
}
private static JSONObject buildGroupProjectPayload() throws Exception {
JSONObject threadMeta = new JSONObject()
.put("threadId", "group-thread-3")