feat: restore dispatch confirmation flows

This commit is contained in:
kris
2026-03-30 17:11:07 +08:00
parent 40861c63da
commit 5eb1246f02
15 changed files with 823 additions and 9 deletions

View File

@@ -90,6 +90,23 @@ public class BossApiClient {
return requestWithRestore("GET", "/api/v1/projects/" + encode(projectId), null);
}
public ApiResponse getDispatchPlans(String projectId) throws IOException, JSONException {
return requestWithRestore("GET", "/api/v1/projects/" + encode(projectId) + "/dispatch-plans", null);
}
public ApiResponse confirmDispatchPlan(String projectId, String planId, JSONArray approvedTargetProjectIds) throws IOException, JSONException {
JSONObject payload = new JSONObject();
payload.put(
"approvedTargetProjectIds",
approvedTargetProjectIds == null ? new JSONArray() : approvedTargetProjectIds
);
return requestWithRestore(
"POST",
"/api/v1/projects/" + encode(projectId) + "/dispatch-plans/" + encode(planId) + "/confirm",
payload
);
}
public ApiResponse renameConversation(String projectId, String name, boolean group) throws IOException, JSONException {
JSONObject payload = new JSONObject();
payload.put("name", name);

View File

@@ -2,6 +2,9 @@ package com.hyzq.boss;
import androidx.annotation.Nullable;
import org.json.JSONArray;
import org.json.JSONObject;
import java.util.ArrayList;
import java.util.Collections;
import java.util.LinkedHashSet;
@@ -215,6 +218,73 @@ public final class ProjectChatUiState {
return "文件";
}
@Nullable
public static JSONObject latestPendingDispatchPlan(@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 ("pending_user_confirmation".equals(plan.optString("status", ""))) {
return plan;
}
}
return null;
}
public static List<String> dispatchPlanApprovedTargetIds(@Nullable JSONObject plan) {
ArrayList<String> approved = new ArrayList<>();
if (plan == null) {
return approved;
}
JSONArray targets = plan.optJSONArray("targets");
if (targets == null) {
return approved;
}
for (int i = 0; i < targets.length(); i++) {
JSONObject target = targets.optJSONObject(i);
if (target == null) {
continue;
}
String projectId = target.optString("projectId", "").trim();
if (!projectId.isEmpty()) {
approved.add(projectId);
}
}
return approved;
}
public static String summarizeDispatchPlan(@Nullable JSONObject plan) {
if (plan == null) {
return "主 Agent 暂未生成推荐线程。";
}
String summary = plan.optString("summary", "").trim();
List<String> targetTitles = new ArrayList<>();
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);
}
}
}
StringBuilder builder = new StringBuilder();
builder.append(isBlank(summary) ? "主 Agent 已生成推荐线程。" : summary);
if (!targetTitles.isEmpty()) {
builder.append("\n推荐线程");
builder.append(String.join("", targetTitles));
}
return builder.toString();
}
public static String formatAttachmentSize(long fileSizeBytes) {
if (fileSizeBytes >= 1024L * 1024L) {
return String.format(java.util.Locale.US, "%.1f MB", fileSizeBytes / (1024f * 1024f));

View File

@@ -59,6 +59,9 @@ public class ProjectDetailActivity extends BossScreenActivity {
private boolean conversationInfoReady;
private String currentScreenTitle;
private String currentScreenSubtitle;
private String projectCollaborationMode = "development";
private String projectApprovalState = "not_required";
private @Nullable JSONObject currentPendingDispatchPlan;
private ProjectChatUiState.SelectionState selectionState = ProjectChatUiState.emptySelection();
private ActivityResultLauncher<Intent> conversationInfoLauncher;
private ActivityResultLauncher<Intent> forwardTargetLauncher;
@@ -216,7 +219,20 @@ public class ProjectDetailActivity extends BossScreenActivity {
if (!response.ok()) {
throw new IllegalStateException(response.message());
}
runOnUiThread(() -> renderProject(response.json));
JSONObject project = response.json.optJSONObject("project");
JSONArray dispatchPlans = null;
if (project != null && project.optBoolean("isGroup", false)) {
try {
BossApiClient.ApiResponse dispatchPlansResponse = apiClient.getDispatchPlans(projectId);
if (dispatchPlansResponse.ok()) {
dispatchPlans = dispatchPlansResponse.json.optJSONArray("plans");
}
} catch (Exception ignored) {
dispatchPlans = null;
}
}
JSONArray finalDispatchPlans = dispatchPlans;
runOnUiThread(() -> renderProject(response.json, finalDispatchPlans));
} catch (Exception error) {
runOnUiThread(() -> {
setRefreshing(false);
@@ -245,7 +261,7 @@ public class ProjectDetailActivity extends BossScreenActivity {
updateSelectionUi();
}
private void renderProject(JSONObject payload) {
private void renderProject(JSONObject payload, @Nullable JSONArray dispatchPlans) {
JSONObject project = payload.optJSONObject("project");
JSONArray devices = payload.optJSONArray("devices");
JSONObject threadMeta = project == null ? null : project.optJSONObject("threadMeta");
@@ -254,12 +270,18 @@ public class ProjectDetailActivity extends BossScreenActivity {
initialProjectName = title;
projectIsGroup = project != null && project.optBoolean("isGroup", false);
projectFolderName = threadMeta == null ? "" : threadMeta.optString("folderName", "");
projectCollaborationMode = project == null ? "development" : project.optString("collaborationMode", "development");
projectApprovalState = project == null ? "not_required" : project.optString("approvalState", "not_required");
currentPendingDispatchPlan = ProjectChatUiState.latestPendingDispatchPlan(dispatchPlans);
conversationInfoReady = project != null;
updateProjectHeader(title, buildProjectSubtitle(projectFolderName, devices));
renderQuickActions();
replaceContent();
pendingOutgoingBubble = null;
if (currentPendingDispatchPlan != null) {
appendContent(buildPendingDispatchPlanView(currentPendingDispatchPlan));
}
JSONArray messages = project == null ? null : project.optJSONArray("messages");
selectionState = ProjectChatUiState.reconcileSelection(selectionState, collectMessageIds(messages));
@@ -437,11 +459,29 @@ public class ProjectDetailActivity extends BossScreenActivity {
if (!response.ok()) {
throw new IllegalStateException(response.message());
}
JSONObject dispatchPlan = response.json.optJSONObject("dispatchPlan");
JSONObject collaborationGate = response.json.optJSONObject("collaborationGate");
runOnUiThread(() -> {
composerSending = false;
composerInput.setText("");
showMessage("消息已发送");
if (collaborationGate != null) {
projectCollaborationMode = collaborationGate.optString("collaborationMode", projectCollaborationMode);
projectApprovalState = collaborationGate.optString("approvalState", projectApprovalState);
}
currentPendingDispatchPlan = dispatchPlan;
if (dispatchPlan != null) {
showMessage(
"approval_required".equals(projectCollaborationMode)
? "消息已发送,等待你批准主 Agent 下发。"
: "消息已发送,主 Agent 已给出推荐线程。"
);
} else {
showMessage("消息已发送");
}
reload(true);
if (dispatchPlan != null) {
showDispatchPlanConfirmation(dispatchPlan);
}
});
} catch (Exception error) {
runOnUiThread(() -> {
@@ -530,6 +570,74 @@ public class ProjectDetailActivity extends BossScreenActivity {
conversationInfoLauncher.launch(intent);
}
private View buildPendingDispatchPlanView(JSONObject dispatchPlan) {
LinearLayout container = new LinearLayout(this);
container.setOrientation(LinearLayout.VERTICAL);
container.addView(BossUi.buildCard(
this,
"approval_required".equals(projectCollaborationMode) ? "等待你批准主 Agent 下发" : "主 Agent 推荐下发",
ProjectChatUiState.summarizeDispatchPlan(dispatchPlan),
"当前确认状态:" + projectApprovalState
));
Button confirmButton = BossUi.buildMiniActionButton(this, "确认下发", true);
confirmButton.setOnClickListener(v -> showDispatchPlanConfirmation(dispatchPlan));
container.addView(BossUi.buildInlineActionRow(this, confirmButton));
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("稍后", null)
.setPositiveButton("确认下发", (dialog, which) -> confirmDispatchPlan(dispatchPlan))
.show();
}
private void confirmDispatchPlan(JSONObject dispatchPlan) {
String planId = dispatchPlan.optString("planId", "").trim();
if (planId.isEmpty()) {
showMessage("缺少调度方案 ID");
return;
}
List<String> approvedTargetProjectIds = ProjectChatUiState.dispatchPlanApprovedTargetIds(dispatchPlan);
if (approvedTargetProjectIds.isEmpty()) {
showMessage("当前没有可下发的目标线程");
return;
}
setRefreshing(true);
executor.execute(() -> {
try {
JSONArray approved = new JSONArray();
for (String approvedTargetProjectId : approvedTargetProjectIds) {
approved.put(approvedTargetProjectId);
}
BossApiClient.ApiResponse response = apiClient.confirmDispatchPlan(projectId, planId, approved);
if (!response.ok()) {
throw new IllegalStateException(response.message());
}
JSONArray executions = response.json.optJSONArray("executions");
int executionCount = executions == null ? approvedTargetProjectIds.size() : executions.length();
runOnUiThread(() -> {
currentPendingDispatchPlan = null;
projectApprovalState = "approval_required".equals(projectCollaborationMode) ? "approved" : "not_required";
showMessage("已确认下发到 " + executionCount + " 个线程");
reload(true);
});
} catch (Exception error) {
runOnUiThread(() -> {
setRefreshing(false);
showMessage("确认下发失败:" + error.getMessage());
});
}
});
}
private View buildMessageView(JSONObject message) {
String messageId = message.optString("id", "");
String senderLabel = message.optString("senderLabel", "消息");

View File

@@ -0,0 +1,241 @@
package com.hyzq.boss;
import static org.junit.Assert.assertEquals;
import android.content.SharedPreferences;
import org.json.JSONArray;
import org.json.JSONObject;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.robolectric.RobolectricTestRunner;
import org.robolectric.annotation.Config;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.HttpURLConnection;
import java.net.ProtocolException;
import java.net.URL;
import java.nio.charset.StandardCharsets;
import java.util.Collections;
import java.util.HashMap;
import java.util.Map;
import java.util.Set;
@RunWith(RobolectricTestRunner.class)
@Config(sdk = 34)
public class BossApiClientDispatchPlansTest {
@Test
public void getDispatchPlansUsesProjectScopedEndpoint() throws Exception {
RecordingConnection connection = new RecordingConnection(new URL("https://boss.hyzq.net/api/v1/projects/p1/dispatch-plans"));
RecordingBossApiClient apiClient = new RecordingBossApiClient(connection);
BossApiClient.ApiResponse response = apiClient.getDispatchPlans("p1");
assertEquals(200, response.statusCode);
assertEquals("/api/v1/projects/p1/dispatch-plans", apiClient.lastPath);
assertEquals("GET", connection.requestMethodValue);
}
@Test
public void confirmDispatchPlanWritesApprovedTargetProjectIds() throws Exception {
RecordingConnection connection = new RecordingConnection(new URL("https://boss.hyzq.net/api/v1/projects/p1/dispatch-plans/plan-1/confirm"));
RecordingBossApiClient apiClient = new RecordingBossApiClient(connection);
BossApiClient.ApiResponse response = apiClient.confirmDispatchPlan("p1", "plan-1", new JSONArray().put("target-1").put("target-2"));
assertEquals(200, response.statusCode);
assertEquals("/api/v1/projects/p1/dispatch-plans/plan-1/confirm", apiClient.lastPath);
assertEquals("POST", connection.requestMethodValue);
assertEquals("{\"approvedTargetProjectIds\":[\"target-1\",\"target-2\"]}", connection.requestBody());
}
private static final class RecordingBossApiClient extends BossApiClient {
private final RecordingConnection connection;
private String lastPath = "";
RecordingBossApiClient(RecordingConnection connection) {
super(new InMemorySharedPreferences(), "https://boss.hyzq.net");
this.connection = connection;
}
@Override
HttpURLConnection openConnection(String path) {
lastPath = path;
return connection;
}
@Override
String encode(String value) {
return value;
}
@Override
void rememberIdentity(JSONObject json) {
// no-op for JVM unit test
}
}
private static final class RecordingConnection extends HttpURLConnection {
private final ByteArrayOutputStream requestBody = new ByteArrayOutputStream();
private final Map<String, String> requestHeaders = new HashMap<>();
private String requestMethodValue = "GET";
RecordingConnection(URL url) {
super(url);
}
@Override
public void disconnect() {}
@Override
public boolean usingProxy() {
return false;
}
@Override
public void connect() {}
@Override
public void setRequestMethod(String method) throws ProtocolException {
requestMethodValue = method;
}
@Override
public void setRequestProperty(String key, String value) {
requestHeaders.put(key, value);
}
@Override
public String getRequestProperty(String key) {
return requestHeaders.get(key);
}
@Override
public OutputStream getOutputStream() {
return requestBody;
}
@Override
public int getResponseCode() {
return 200;
}
@Override
public InputStream getInputStream() {
return new ByteArrayInputStream("{\"ok\":true}".getBytes(StandardCharsets.UTF_8));
}
String requestBody() {
return requestBody.toString(StandardCharsets.UTF_8);
}
}
private static final class InMemorySharedPreferences implements SharedPreferences {
private final Map<String, String> values = new HashMap<>();
@Override
public Map<String, ?> getAll() {
return Collections.unmodifiableMap(values);
}
@Override
public String getString(String key, String defValue) {
return values.getOrDefault(key, defValue);
}
@Override
public Set<String> getStringSet(String key, Set<String> defValues) {
throw new UnsupportedOperationException();
}
@Override
public int getInt(String key, int defValue) {
throw new UnsupportedOperationException();
}
@Override
public long getLong(String key, long defValue) {
throw new UnsupportedOperationException();
}
@Override
public float getFloat(String key, float defValue) {
throw new UnsupportedOperationException();
}
@Override
public boolean getBoolean(String key, boolean defValue) {
throw new UnsupportedOperationException();
}
@Override
public boolean contains(String key) {
return values.containsKey(key);
}
@Override
public Editor edit() {
return new Editor() {
@Override
public Editor putString(String key, String value) {
values.put(key, value);
return this;
}
@Override
public Editor remove(String key) {
values.remove(key);
return this;
}
@Override
public Editor clear() {
values.clear();
return this;
}
@Override
public void apply() {}
@Override
public boolean commit() {
return true;
}
@Override
public Editor putStringSet(String key, Set<String> values) {
throw new UnsupportedOperationException();
}
@Override
public Editor putInt(String key, int value) {
throw new UnsupportedOperationException();
}
@Override
public Editor putLong(String key, long value) {
throw new UnsupportedOperationException();
}
@Override
public Editor putFloat(String key, float value) {
throw new UnsupportedOperationException();
}
@Override
public Editor putBoolean(String key, boolean value) {
throw new UnsupportedOperationException();
}
};
}
@Override
public void registerOnSharedPreferenceChangeListener(OnSharedPreferenceChangeListener listener) {}
@Override
public void unregisterOnSharedPreferenceChangeListener(OnSharedPreferenceChangeListener listener) {}
}
}

View File

@@ -6,11 +6,18 @@ import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertTrue;
import static org.junit.Assert.fail;
import org.json.JSONArray;
import org.json.JSONObject;
import java.util.ArrayList;
import java.util.List;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.robolectric.RobolectricTestRunner;
import org.robolectric.annotation.Config;
@RunWith(RobolectricTestRunner.class)
@Config(sdk = 34)
public class ProjectChatUiStateTest {
@Test
public void sendEnabled_requiresTextAndNotBusy() {
@@ -158,4 +165,33 @@ public class ProjectChatUiStateTest {
assertTrue(summary.endsWith(""));
assertTrue(summary.contains("这是一条很长很长很长的转发消息摘要"));
}
@Test
public void dispatchPlanSummaryShowsRecommendedTargetNames() throws Exception {
JSONObject plan = new JSONObject()
.put("summary", "主 Agent 建议先同步 UI 和设备线程")
.put("targets", new JSONArray()
.put(new JSONObject()
.put("projectId", "p1")
.put("threadDisplayName", "Boss UI 主线程"))
.put(new JSONObject()
.put("projectId", "p2")
.put("threadDisplayName", "设备接入线程")));
String summary = ProjectChatUiState.summarizeDispatchPlan(plan);
assertEquals("主 Agent 建议先同步 UI 和设备线程\n推荐线程Boss UI 主线程、设备接入线程", summary);
}
@Test
public void dispatchPlanApprovedTargetIdsFollowRecommendedOrder() throws Exception {
JSONObject plan = new JSONObject()
.put("targets", new JSONArray()
.put(new JSONObject().put("projectId", "p2").put("threadDisplayName", "设备接入线程"))
.put(new JSONObject().put("projectId", "p1").put("threadDisplayName", "Boss UI 主线程")));
List<String> approvedTargetIds = ProjectChatUiState.dispatchPlanApprovedTargetIds(plan);
assertEquals(List.of("p2", "p1"), approvedTargetIds);
}
}