feat: queue master-agent chat replies

This commit is contained in:
kris
2026-03-31 19:59:08 +08:00
parent e741952295
commit 013d9566be
8 changed files with 930 additions and 17 deletions

View File

@@ -97,6 +97,21 @@ public class BossApiClient {
return requestWithRestore("GET", "/api/v1/projects/" + encode(projectId) + "/dispatch-plans", null);
}
public ApiResponse getProjectAgentControls(String projectId) throws IOException, JSONException {
return requestWithRestore("GET", "/api/v1/projects/" + encode(projectId) + "/agent-controls", null);
}
public ApiResponse updateProjectAgentControls(
String projectId,
@Nullable String modelOverride,
@Nullable String reasoningEffortOverride
) throws IOException, JSONException {
JSONObject payload = new JSONObject();
payload.put("modelOverride", modelOverride == null ? JSONObject.NULL : modelOverride);
payload.put("reasoningEffortOverride", reasoningEffortOverride == null ? JSONObject.NULL : reasoningEffortOverride);
return requestWithRestore("POST", "/api/v1/projects/" + encode(projectId) + "/agent-controls", payload);
}
public ApiResponse confirmDispatchPlan(String projectId, String planId, JSONArray approvedTargetProjectIds) throws IOException, JSONException {
JSONObject payload = new JSONObject();
payload.put(

View File

@@ -35,6 +35,8 @@ import java.io.FileOutputStream;
import java.io.InputStream;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
public class ProjectDetailActivity extends BossScreenActivity {
public static final String EXTRA_PROJECT_ID = "project_id";
@@ -46,6 +48,8 @@ public class ProjectDetailActivity extends BossScreenActivity {
private String initialProjectName;
private boolean projectIsGroup;
private String projectFolderName;
private @Nullable String currentAgentModelOverride;
private @Nullable String currentReasoningEffortOverride;
private LinearLayout quickActionsLayout;
private LinearLayout composerRow;
private LinearLayout multiSelectActionsLayout;
@@ -59,6 +63,8 @@ public class ProjectDetailActivity extends BossScreenActivity {
private boolean renderNearBottom;
private boolean renderForcedScrollToBottom;
private boolean conversationInfoReady;
private boolean masterAgentReplyWaiting;
private @Nullable String masterAgentReplyBaselineMessageId;
private String currentScreenTitle;
private String currentScreenSubtitle;
private String projectCollaborationMode = "development";
@@ -70,6 +76,7 @@ public class ProjectDetailActivity extends BossScreenActivity {
private ActivityResultLauncher<String> imagePickerLauncher;
private ActivityResultLauncher<String> videoPickerLauncher;
private ActivityResultLauncher<String> filePickerLauncher;
private final ExecutorService replyWaitExecutor = Executors.newSingleThreadExecutor();
static final class ChromeBindings {
final boolean multiSelecting;
@@ -212,6 +219,12 @@ public class ProjectDetailActivity extends BossScreenActivity {
}
}
@Override
protected void onDestroy() {
replyWaitExecutor.shutdownNow();
super.onDestroy();
}
boolean shouldLoadOnCreate() {
return true;
}
@@ -239,7 +252,7 @@ public class ProjectDetailActivity extends BossScreenActivity {
setRefreshing(false);
composerSending = false;
updateComposerSendButtonState();
if (pendingOutgoingBubble == null) {
if (pendingOutgoingBubble == null && !masterAgentReplyWaiting) {
replaceContent(BossUi.buildEmptyCard(this, "项目详情加载失败:" + error.getMessage()));
} else {
showMessage("项目详情刷新失败:" + error.getMessage());
@@ -277,6 +290,9 @@ 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");
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));
currentPendingDispatchPlan = ProjectChatUiState.latestPendingDispatchPlan(dispatchPlans);
conversationInfoReady = project != null;
updateProjectHeader(title, buildProjectSubtitle(projectFolderName, devices));
@@ -305,6 +321,17 @@ public class ProjectDetailActivity extends BossScreenActivity {
appendContent(BossUi.buildMessagePlaceholder(this, "还没有项目消息,先发一条开始对话。"));
}
boolean masterAgentStillWaiting = isMasterAgentConversation()
&& masterAgentReplyWaiting
&& !ProjectChatUiState.hasReplyBeyondBaseline(project, masterAgentReplyBaselineMessageId);
if (isMasterAgentConversation() && masterAgentReplyWaiting && !masterAgentStillWaiting) {
masterAgentReplyWaiting = false;
masterAgentReplyBaselineMessageId = null;
}
if (masterAgentStillWaiting) {
appendContent(buildMasterAgentThinkingPlaceholder());
}
setRefreshing(false);
updateSelectionUi();
if (ProjectChatUiState.shouldAutoScroll(renderNearBottom, renderForcedScrollToBottom)) {
@@ -491,7 +518,11 @@ public class ProjectDetailActivity extends BossScreenActivity {
return;
}
if (waitSpec.shouldWait) {
startReplyWait(waitSpec, false, "消息已发送,正在等待回复…");
if (isMasterAgentConversation()) {
startMasterAgentReplyWait(waitSpec, false, "消息已发送,主 Agent 思考中");
} else {
startReplyWait(waitSpec, false, "消息已发送,正在等待回复…");
}
return;
}
composerSending = false;
@@ -586,6 +617,162 @@ public class ProjectDetailActivity extends BossScreenActivity {
conversationInfoLauncher.launch(intent);
}
private void showMasterAgentMoreMenu() {
if (!isMasterAgentConversation()) {
return;
}
new AlertDialog.Builder(this)
.setItems(new CharSequence[]{"模型", "推理强度", "会话信息", "刷新"}, (dialog, which) -> {
switch (which) {
case 0:
showMasterAgentModelPicker();
break;
case 1:
showMasterAgentReasoningPicker();
break;
case 2:
openConversationInfo();
break;
case 3:
reload(true);
break;
default:
dialog.dismiss();
break;
}
})
.show();
}
private void showMasterAgentModelPicker() {
if (!isMasterAgentConversation()) {
return;
}
final String[] options = buildMasterAgentModelOptions();
int checkedIndex = findCheckedIndex(options, currentAgentModelOverride);
new AlertDialog.Builder(this)
.setTitle("模型")
.setSingleChoiceItems(options, checkedIndex, (dialog, which) -> {
if (which == 0) {
dialog.dismiss();
updateMasterAgentControls(null, currentReasoningEffortOverride, "模型已恢复默认");
return;
}
if (which == options.length - 1) {
dialog.dismiss();
showCustomMasterAgentModelDialog();
return;
}
dialog.dismiss();
updateMasterAgentControls(options[which], currentReasoningEffortOverride, "模型已更新为 " + options[which]);
})
.setNegativeButton("取消", null)
.show();
}
private void showCustomMasterAgentModelDialog() {
final EditText input = BossUi.buildInput(this, "模型,例如 gpt-5.4", false);
input.setText(TextUtils.isEmpty(currentAgentModelOverride) ? "gpt-5.4" : currentAgentModelOverride);
new AlertDialog.Builder(this)
.setTitle("自定义模型")
.setView(input)
.setNegativeButton("取消", null)
.setPositiveButton("保存", (dialog, which) ->
updateMasterAgentControls(
normalizeControlValue(input.getText() == null ? null : input.getText().toString()),
currentReasoningEffortOverride,
"模型已更新"
))
.show();
}
private void showMasterAgentReasoningPicker() {
if (!isMasterAgentConversation()) {
return;
}
final String[] options = new String[]{"沿用默认", "low", "medium", "high"};
int checkedIndex = findCheckedIndex(options, currentReasoningEffortOverride);
new AlertDialog.Builder(this)
.setTitle("推理强度")
.setSingleChoiceItems(options, checkedIndex, (dialog, which) -> {
dialog.dismiss();
String reasoningOverride = which == 0 ? null : options[which];
updateMasterAgentControls(
currentAgentModelOverride,
reasoningOverride,
which == 0 ? "推理强度已恢复默认" : "推理强度已更新为 " + options[which]
);
})
.setNegativeButton("取消", null)
.show();
}
private void updateMasterAgentControls(
@Nullable String modelOverride,
@Nullable String reasoningEffortOverride,
String successMessage
) {
if (!isMasterAgentConversation() || projectId == null || projectId.isEmpty()) {
return;
}
setRefreshing(true);
executor.execute(() -> {
try {
BossApiClient.ApiResponse response = apiClient.updateProjectAgentControls(
projectId,
modelOverride,
reasoningEffortOverride
);
if (!response.ok()) {
throw new IllegalStateException(response.message());
}
JSONObject controls = response.json.optJSONObject("controls");
runOnUiThread(() -> {
currentAgentModelOverride = normalizeControlValue(controls == null ? null : controls.optString("modelOverride", null));
currentReasoningEffortOverride = normalizeControlValue(controls == null ? null : controls.optString("reasoningEffortOverride", null));
showMessage(successMessage);
reload(true);
});
} catch (Exception error) {
runOnUiThread(() -> {
setRefreshing(false);
showMessage("保存失败:" + error.getMessage());
});
}
});
}
private String[] buildMasterAgentModelOptions() {
List<String> options = new ArrayList<>();
options.add("沿用默认");
if (!TextUtils.isEmpty(currentAgentModelOverride)) {
options.add(currentAgentModelOverride);
}
if (!options.contains("gpt-5.4")) {
options.add("gpt-5.4");
}
if (!options.contains("gpt-5.1")) {
options.add("gpt-5.1");
}
if (!options.contains("gpt-4.1")) {
options.add("gpt-4.1");
}
options.add("自定义...");
return options.toArray(new String[0]);
}
private int findCheckedIndex(String[] options, @Nullable String selectedValue) {
if (TextUtils.isEmpty(selectedValue)) {
return 0;
}
for (int i = 0; i < options.length; i++) {
if (selectedValue.equals(options[i])) {
return i;
}
}
return 0;
}
private View buildPendingDispatchPlanView(JSONObject dispatchPlan) {
LinearLayout container = new LinearLayout(this);
container.setOrientation(LinearLayout.VERTICAL);
@@ -1154,10 +1341,14 @@ public class ProjectDetailActivity extends BossScreenActivity {
}
finish();
});
refreshButton.setVisibility(bindings.showRefresh ? View.VISIBLE : View.GONE);
refreshButton.setVisibility(bindings.showRefresh && !isMasterAgentConversation() ? View.VISIBLE : View.GONE);
titleView.setText(bindings.title);
subtitleView.setText(bindings.subtitle);
if (bindings.showHeaderAction) {
if (bindings.multiSelecting) {
hideHeaderAction();
} else if (isMasterAgentConversation()) {
setHeaderAction("...", v -> showMasterAgentMoreMenu());
} else if (bindings.showHeaderAction) {
setHeaderAction(WechatSurfaceMapper.conversationInfoActionLabel(), v -> openConversationInfo());
} else {
hideHeaderAction();
@@ -1214,6 +1405,22 @@ public class ProjectDetailActivity extends BossScreenActivity {
return folderName + " · 设备:" + deviceLabel;
}
private boolean isMasterAgentConversation() {
return "master-agent".equals(projectId);
}
@Nullable
private String normalizeControlValue(@Nullable String value) {
if (TextUtils.isEmpty(value)) {
return null;
}
return value.trim();
}
private View buildMasterAgentThinkingPlaceholder() {
return BossUi.buildHintPill(this, "主 Agent 思考中");
}
private void scrollChatToBottom() {
if (chatScrollView == null) {
return;
@@ -1597,6 +1804,10 @@ public class ProjectDetailActivity extends BossScreenActivity {
boolean includeDispatchPlans,
String waitingMessage
) {
if (isMasterAgentConversation()) {
startMasterAgentReplyWait(waitSpec, includeDispatchPlans, waitingMessage);
return;
}
composerSending = true;
updateComposerSendButtonState();
setRefreshing(true);
@@ -1604,6 +1815,21 @@ public class ProjectDetailActivity extends BossScreenActivity {
executor.execute(() -> pollUntilReply(waitSpec, includeDispatchPlans));
}
private void startMasterAgentReplyWait(
ProjectChatUiState.ReplyWaitSpec waitSpec,
boolean includeDispatchPlans,
String waitingMessage
) {
masterAgentReplyWaiting = true;
masterAgentReplyBaselineMessageId = waitSpec.baselineMessageId;
composerSending = false;
updateComposerSendButtonState();
setRefreshing(false);
showMessage(waitingMessage);
reload(true);
replyWaitExecutor.execute(() -> pollUntilReply(waitSpec, includeDispatchPlans));
}
private void pollUntilReply(
ProjectChatUiState.ReplyWaitSpec waitSpec,
boolean includeDispatchPlans
@@ -1619,7 +1845,7 @@ public class ProjectDetailActivity extends BossScreenActivity {
if (!renderedInitialSnapshot || hasReply) {
runOnUiThread(() -> {
renderProject(snapshot.payload, snapshot.dispatchPlans, snapshot.participantsPayload);
if (!hasReply) {
if (!hasReply && !isMasterAgentConversation()) {
composerSending = true;
updateComposerSendButtonState();
setRefreshing(true);
@@ -1630,6 +1856,10 @@ public class ProjectDetailActivity extends BossScreenActivity {
if (hasReply) {
runOnUiThread(() -> {
if (isMasterAgentConversation()) {
masterAgentReplyWaiting = false;
masterAgentReplyBaselineMessageId = null;
}
composerSending = false;
updateComposerSendButtonState();
setRefreshing(false);
@@ -1642,6 +1872,10 @@ public class ProjectDetailActivity extends BossScreenActivity {
}
runOnUiThread(() -> {
if (isMasterAgentConversation()) {
masterAgentReplyWaiting = false;
masterAgentReplyBaselineMessageId = null;
}
composerSending = false;
updateComposerSendButtonState();
setRefreshing(false);
@@ -1650,6 +1884,10 @@ public class ProjectDetailActivity extends BossScreenActivity {
});
} catch (Exception error) {
runOnUiThread(() -> {
if (isMasterAgentConversation()) {
masterAgentReplyWaiting = false;
masterAgentReplyBaselineMessageId = null;
}
composerSending = false;
updateComposerSendButtonState();
setRefreshing(false);

View File

@@ -69,6 +69,31 @@ public class BossApiClientDispatchPlansTest {
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"));
RecordingBossApiClient apiClient = new RecordingBossApiClient(connection);
BossApiClient.ApiResponse response = apiClient.getProjectAgentControls("master-agent");
assertEquals(200, response.statusCode);
assertEquals("/api/v1/projects/master-agent/agent-controls", apiClient.lastPath);
assertEquals("GET", connection.requestMethodValue);
}
@Test
public void updateProjectAgentControlsWritesModelAndReasoningOverrides() throws Exception {
RecordingConnection connection = new RecordingConnection(new URL("https://boss.hyzq.net/api/v1/projects/master-agent/agent-controls"));
RecordingBossApiClient apiClient = new RecordingBossApiClient(connection);
BossApiClient.ApiResponse response = apiClient.updateProjectAgentControls("master-agent", "gpt-5.4", "high");
assertEquals(200, response.statusCode);
assertEquals("/api/v1/projects/master-agent/agent-controls", apiClient.lastPath);
assertEquals("POST", connection.requestMethodValue);
assertEquals("{\"modelOverride\":\"gpt-5.4\",\"reasoningEffortOverride\":\"high\"}", connection.requestBody());
}
@Test
public void sendProjectMessageUsesExtendedReadTimeoutForMasterAgent() throws Exception {
RecordingConnection connection = new RecordingConnection(new URL("https://boss.hyzq.net/api/v1/projects/master-agent/messages"));

View File

@@ -0,0 +1,113 @@
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.view.View;
import android.view.ViewGroup;
import android.widget.ListView;
import android.widget.TextView;
import androidx.appcompat.app.AlertDialog;
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.shadows.ShadowDialog;
import org.robolectric.util.ReflectionHelpers;
@RunWith(RobolectricTestRunner.class)
@Config(sdk = 34)
public class ProjectDetailActivityMasterAgentMenuTest {
@Test
public void masterAgentMoreMenuShowsWechatActions() {
Intent intent = new Intent()
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_ID, "master-agent")
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_NAME, "主 Agent");
ProjectDetailActivityUiTest.TestProjectDetailActivity activity = Robolectric
.buildActivity(ProjectDetailActivityUiTest.TestProjectDetailActivity.class, intent)
.setup()
.get();
ReflectionHelpers.callInstanceMethod(activity, "showMasterAgentMoreMenu");
android.app.Dialog latestDialog = ShadowDialog.getLatestDialog();
assertTrue(latestDialog instanceof AlertDialog);
AlertDialog actionDialog = (AlertDialog) latestDialog;
ListView listView = actionDialog.getListView();
assertMenuItem(listView, 0, "模型");
assertMenuItem(listView, 1, "推理强度");
assertMenuItem(listView, 2, "会话信息");
assertMenuItem(listView, 3, "刷新");
}
@Test
public void masterAgentWaitingStateRendersThinkingPlaceholder() throws Exception {
Intent intent = new Intent()
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_ID, "master-agent")
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_NAME, "主 Agent");
ProjectDetailActivityUiTest.TestProjectDetailActivity activity = Robolectric
.buildActivity(ProjectDetailActivityUiTest.TestProjectDetailActivity.class, intent)
.setup()
.get();
ReflectionHelpers.setField(activity, "conversationInfoReady", true);
ReflectionHelpers.setField(activity, "masterAgentReplyWaiting", true);
ReflectionHelpers.setField(activity, "masterAgentReplyBaselineMessageId", "msg-user-1");
JSONObject project = new JSONObject()
.put("id", "master-agent")
.put("name", "主 Agent")
.put("messages", new JSONArray()
.put(new JSONObject()
.put("id", "msg-user-1")
.put("sender", "user")
.put("senderLabel", "Boss 超级管理员")
.put("body", "帮我检查当前主控")
.put("kind", "text")));
JSONObject payload = new JSONObject().put("project", project);
ReflectionHelpers.callInstanceMethod(
activity,
"renderProject",
ReflectionHelpers.ClassParameter.from(JSONObject.class, payload),
ReflectionHelpers.ClassParameter.from(JSONArray.class, null),
ReflectionHelpers.ClassParameter.from(JSONObject.class, null)
);
View contentRoot = activity.findViewById(R.id.screen_content);
assertNotNull(contentRoot);
assertTrue(viewTreeContainsText(contentRoot, "主 Agent 思考中"));
}
private static void assertMenuItem(ListView listView, int index, String expectedText) {
View item = listView.getAdapter().getView(index, null, listView);
assertTrue(viewTreeContainsText(item, expectedText));
}
private static boolean viewTreeContainsText(View root, String expectedText) {
if (root instanceof TextView) {
CharSequence text = ((TextView) root).getText();
if (expectedText.contentEquals(text)) {
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;
}
}

View File

@@ -232,6 +232,27 @@ public class ProjectDetailActivityUiTest {
assertFalse(viewTreeContainsText(messageView, "Boss 超级管理员 · 10:26"));
}
@Test
public void masterAgentHeaderUsesWechatMoreMenuLabel() {
Intent intent = new Intent()
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_ID, "master-agent")
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_NAME, "主 Agent");
TestProjectDetailActivity activity = Robolectric
.buildActivity(TestProjectDetailActivity.class, intent)
.setup()
.get();
ReflectionHelpers.setField(activity, "conversationInfoReady", true);
ReflectionHelpers.setField(activity, "currentScreenTitle", "主 Agent");
ReflectionHelpers.setField(activity, "currentScreenSubtitle", "单聊会话");
ReflectionHelpers.callInstanceMethod(activity, "updateSelectionUi");
Button headerAction = activity.findViewById(R.id.screen_header_action);
assertEquals(View.VISIBLE, headerAction.getVisibility());
assertEquals("...", headerAction.getText().toString());
}
@Test
public void outgoingAttachmentMetaPrefersTimeOnly() throws Exception {
Intent intent = new Intent()