feat: add dispatch retry and import recovery flows
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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());
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"));
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user