diff --git a/android/app/src/main/java/com/hyzq/boss/BossApiClient.java b/android/app/src/main/java/com/hyzq/boss/BossApiClient.java index 2e8e9bc..ed42039 100644 --- a/android/app/src/main/java/com/hyzq/boss/BossApiClient.java +++ b/android/app/src/main/java/com/hyzq/boss/BossApiClient.java @@ -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( diff --git a/android/app/src/main/java/com/hyzq/boss/ProjectDetailActivity.java b/android/app/src/main/java/com/hyzq/boss/ProjectDetailActivity.java index c68cd77..d590850 100644 --- a/android/app/src/main/java/com/hyzq/boss/ProjectDetailActivity.java +++ b/android/app/src/main/java/com/hyzq/boss/ProjectDetailActivity.java @@ -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 imagePickerLauncher; private ActivityResultLauncher videoPickerLauncher; private ActivityResultLauncher 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 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); diff --git a/android/app/src/test/java/com/hyzq/boss/BossApiClientDispatchPlansTest.java b/android/app/src/test/java/com/hyzq/boss/BossApiClientDispatchPlansTest.java index eeedb54..52a12ed 100644 --- a/android/app/src/test/java/com/hyzq/boss/BossApiClientDispatchPlansTest.java +++ b/android/app/src/test/java/com/hyzq/boss/BossApiClientDispatchPlansTest.java @@ -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")); diff --git a/android/app/src/test/java/com/hyzq/boss/ProjectDetailActivityMasterAgentMenuTest.java b/android/app/src/test/java/com/hyzq/boss/ProjectDetailActivityMasterAgentMenuTest.java new file mode 100644 index 0000000..aa75359 --- /dev/null +++ b/android/app/src/test/java/com/hyzq/boss/ProjectDetailActivityMasterAgentMenuTest.java @@ -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; + } +} diff --git a/android/app/src/test/java/com/hyzq/boss/ProjectDetailActivityUiTest.java b/android/app/src/test/java/com/hyzq/boss/ProjectDetailActivityUiTest.java index d313738..5d3a0a0 100644 --- a/android/app/src/test/java/com/hyzq/boss/ProjectDetailActivityUiTest.java +++ b/android/app/src/test/java/com/hyzq/boss/ProjectDetailActivityUiTest.java @@ -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() diff --git a/src/app/api/v1/projects/[projectId]/messages/route.ts b/src/app/api/v1/projects/[projectId]/messages/route.ts index 3360b62..3eefb8e 100644 --- a/src/app/api/v1/projects/[projectId]/messages/route.ts +++ b/src/app/api/v1/projects/[projectId]/messages/route.ts @@ -103,15 +103,26 @@ export async function POST( accountId?: string; requestId?: string; taskId?: string; + masterReplyState?: "queued" | "running" | "completed"; + task?: { + taskId: string; + taskType: "conversation_reply"; + status: "queued" | "running" | "completed"; + }; } | undefined; let task: | { taskId: string; taskType: "conversation_reply"; - status: "queued" | "completed"; + status: "queued" | "running" | "completed"; } | null = null; + let masterReplyState: + | "queued" + | "running" + | "completed" + | null = null; if (shouldCreateDispatchPlan) { try { @@ -173,13 +184,13 @@ export async function POST( requestedBy: session.displayName, requestedByAccount: session.account, currentSessionExpiresAt: session.expiresAt, + mode: "enqueue", }); if (masterReply?.ok && masterReply.taskId) { - task = { - taskId: masterReply.taskId, - taskType: "conversation_reply", - status: masterReply.requestId ? "completed" : "queued", - }; + task = masterReply.task ?? null; + masterReplyState = masterReply.masterReplyState ?? null; + } else { + masterReplyState = null; } } @@ -192,6 +203,7 @@ export async function POST( message, masterReply, task, + masterReplyState, dispatchPlan, dispatchRecommendation, collaborationGate, diff --git a/src/lib/boss-master-agent.ts b/src/lib/boss-master-agent.ts index 0b294e5..86084fa 100644 --- a/src/lib/boss-master-agent.ts +++ b/src/lib/boss-master-agent.ts @@ -7,6 +7,7 @@ import { completeMasterAgentTask, getProjectAttachment, getAttachmentStorageConfig, + getProjectAgentControls, getLatestDeviceImportDraft, getRuntimeAiAccountById, getMasterAgentRuntimeAccount, @@ -18,11 +19,38 @@ import { updateAttachmentAnalysisResult, updateAiAccountHealth, } from "@/lib/boss-data"; -import type { DispatchPlanTarget, Project } from "@/lib/boss-data"; +import type { DispatchPlanTarget, Project, ProjectAgentControls, ReasoningEffort } from "@/lib/boss-data"; import { canInlineAttachmentText, extractAttachmentTextExcerpt } from "@/lib/boss-attachments"; import { readAliyunOssObjectBuffer } from "@/lib/boss-storage-aliyun-oss"; import { readServerFileAttachmentBuffer } from "@/lib/boss-storage-server-file"; +type MasterAgentReplyState = "queued" | "running" | "completed"; +const OPENAI_MASTER_AGENT_DEVICE_ID = "master-agent-openai"; + +type QueuedMasterAgentReplyEnvelope = { + ok: true; + accountId: string; + taskId: string; + masterReplyState: MasterAgentReplyState; + task: { + taskId: string; + taskType: "conversation_reply"; + status: MasterAgentReplyState; + }; +}; + +function buildAgentControlsDigest(agentControls?: ProjectAgentControls | null) { + if (!agentControls) { + return "当前对话覆盖:无"; + } + + return [ + "当前对话覆盖:", + `model=${agentControls.modelOverride ?? "默认"}`, + `reasoning=${agentControls.reasoningEffortOverride ?? "默认"}`, + ].join(" "); +} + function buildMasterAgentInstructions() { return [ "你是 Boss 控制台的主 Agent。", @@ -53,6 +81,7 @@ function buildRuntimeDigest( state: Awaited>, requestText: string, currentSessionExpiresAt?: string, + agentControls?: ProjectAgentControls | null, ) { const recentMessages = state.projects .find((project) => project.id === "master-agent") @@ -91,6 +120,7 @@ function buildRuntimeDigest( `登录会话策略:成功登录后默认保持 ${Math.round(AUTH_SESSION_TTL_MS / 24 / 60 / 60_000)} 天。`, "Cookie Max-Age:2592000 秒。", currentSessionExpiresAt ? `当前请求会话到期时间:${currentSessionExpiresAt}` : undefined, + buildAgentControlsDigest(agentControls), ] .filter(Boolean) .join("\n"); @@ -210,6 +240,7 @@ async function replyViaOpenAiAccount(params: { requestText: string; currentSessionExpiresAt?: string; senderLabel: string; + agentControls?: ProjectAgentControls | null; }) { if (!params.account?.apiKey?.trim()) { throw new Error("OPENAI_ACCOUNT_NOT_CONFIGURED"); @@ -217,9 +248,11 @@ async function replyViaOpenAiAccount(params: { const generated = await generateOpenAiReply({ apiKey: params.account.apiKey, - model: params.account.model || "gpt-5.4", + model: params.agentControls?.modelOverride || params.account.model || "gpt-5.4", + reasoningEffort: params.agentControls?.reasoningEffortOverride || "medium", requestText: params.requestText, currentSessionExpiresAt: params.currentSessionExpiresAt, + agentControls: params.agentControls, }); await appendMasterAgentSystemReply(generated.content, params.senderLabel); @@ -240,8 +273,10 @@ async function replyViaOpenAiAccount(params: { async function generateOpenAiReply(params: { apiKey: string; model: string; + reasoningEffort: ReasoningEffort; requestText: string; currentSessionExpiresAt?: string; + agentControls?: ProjectAgentControls | null; }) { const state = await readState(); let response: Response; @@ -254,9 +289,14 @@ async function generateOpenAiReply(params: { }, body: JSON.stringify({ model: params.model, - reasoning: { effort: "medium" }, + reasoning: { effort: params.reasoningEffort }, instructions: buildMasterAgentInstructions(), - input: buildRuntimeDigest(state, params.requestText, params.currentSessionExpiresAt), + input: buildRuntimeDigest( + state, + params.requestText, + params.currentSessionExpiresAt, + params.agentControls, + ), }), signal: AbortSignal.timeout(45_000), }); @@ -296,6 +336,120 @@ async function generateOpenAiReply(params: { }; } +function buildMasterOpenAiReplyPrompt( + state: Awaited>, + requestText: string, + currentSessionExpiresAt?: string, + agentControls?: ProjectAgentControls | null, +) { + return [ + buildMasterAgentInstructions(), + "", + buildRuntimeDigest(state, requestText, currentSessionExpiresAt, agentControls), + ].join("\n"); +} + +async function queueAndStartOpenAiMasterAgentReply(params: { + taskId: string; + deviceId: string; + requestText: string; + currentSessionExpiresAt?: string; + apiKey: string; + model: string; + reasoningEffort: ReasoningEffort; + agentControls?: ProjectAgentControls | null; +}) { + const timer = setTimeout(() => { + void (async () => { + const task = await getMasterAgentTask(params.taskId); + if (!task || task.status !== "queued") { + return; + } + + try { + const generated = await generateOpenAiReply({ + apiKey: params.apiKey, + model: params.model, + reasoningEffort: params.reasoningEffort, + requestText: params.requestText, + currentSessionExpiresAt: params.currentSessionExpiresAt, + agentControls: params.agentControls, + }); + + await completeMasterAgentTask({ + taskId: params.taskId, + deviceId: params.deviceId, + status: "completed", + replyBody: generated.content, + requestId: generated.requestId, + }); + } catch (error) { + await completeMasterAgentTask({ + taskId: params.taskId, + deviceId: params.deviceId, + status: "failed", + errorMessage: error instanceof Error ? error.message : "主 Agent 当前调用模型失败。", + }); + } + })(); + }, 0); + timer.unref?.(); +} + +async function enqueueOpenAiMasterAgentReply(params: { + accountId: string; + accountLabel: string; + requestMessageId?: string; + requestText: string; + requestedBy: string; + requestedByAccount: string; + currentSessionExpiresAt?: string; + apiKey: string; + model: string; + reasoningEffort: ReasoningEffort; + agentControls?: ProjectAgentControls | null; +}) { + const state = await readState(); + const task = await queueMasterAgentTask({ + requestMessageId: params.requestMessageId ?? "master-agent-manual", + requestText: params.requestText, + executionPrompt: buildMasterOpenAiReplyPrompt( + state, + params.requestText, + params.currentSessionExpiresAt, + params.agentControls, + ), + requestedBy: params.requestedBy, + requestedByAccount: params.requestedByAccount, + deviceId: OPENAI_MASTER_AGENT_DEVICE_ID, + accountId: params.accountId, + accountLabel: params.accountLabel, + }); + void queueAndStartOpenAiMasterAgentReply({ + taskId: task.taskId, + deviceId: OPENAI_MASTER_AGENT_DEVICE_ID, + requestText: params.requestText, + currentSessionExpiresAt: params.currentSessionExpiresAt, + apiKey: params.apiKey, + model: params.model, + reasoningEffort: params.reasoningEffort, + agentControls: params.agentControls, + }); + + const queuedReply: QueuedMasterAgentReplyEnvelope = { + ok: true as const, + accountId: params.accountId, + taskId: task.taskId, + masterReplyState: "queued" as const, + task: { + taskId: task.taskId, + taskType: "conversation_reply" as const, + status: "queued" as const, + }, + }; + return queuedReply; +} + export async function probeOpenAiApiAccount(params: { apiKey: string; model?: string; @@ -366,14 +520,16 @@ function buildMasterCodexNodePrompt( state: Awaited>, requestText: string, currentSessionExpiresAt?: string, + agentControls?: ProjectAgentControls | null, ) { return [ "你是 Boss 控制台的主 Agent,运行在用户自己的 Master Codex Node 上。", "请结合下面的运行时状态和用户消息,直接给出中文回复。", "如果你认为需要继续在当前仓库里推进实现、排障或验证,可以直接说明你下一步会做什么;如果必须先做交接或收尾,也要明确说出原因。", "保持简洁,优先给出结论、动作、验证点。", + buildAgentControlsDigest(agentControls), "", - buildRuntimeDigest(state, requestText, currentSessionExpiresAt), + buildRuntimeDigest(state, requestText, currentSessionExpiresAt, agentControls), ].join("\n"); } @@ -1039,8 +1195,10 @@ export async function replyToMasterAgentUserMessage(params: { requestedBy: string; requestedByAccount: string; currentSessionExpiresAt?: string; + mode?: "wait" | "enqueue"; }) { const runtime = await getMasterAgentRuntimeAccount(); + const agentControls = await getProjectAgentControls("master-agent"); if (!runtime?.account) { await appendMasterAgentSystemReply( @@ -1049,6 +1207,96 @@ export async function replyToMasterAgentUserMessage(params: { return { ok: false as const, reason: "NO_AI_ACCOUNT" }; } + if (params.mode === "enqueue") { + if (runtime.account.provider === "master_codex_node") { + const state = await readState(); + const deviceId = runtime.account.nodeId || state.user.boundDeviceId || "mac-studio"; + const boundDevice = state.devices.find((device) => device.id === deviceId); + const boundNodeLabel = + runtime.account.nodeLabel?.trim() || + boundDevice?.name || + state.user.boundCodexNodeLabel || + deviceId; + + if (!boundDevice || boundDevice.status !== "online") { + await updateAiAccountHealth({ + accountId: runtime.account.accountId, + status: "degraded", + lastError: !boundDevice ? "MASTER_CODEX_NODE_DEVICE_NOT_FOUND" : "MASTER_CODEX_NODE_DEVICE_OFFLINE", + lastValidatedAt: new Date().toISOString(), + }); + + const fallbackAccount = await findFallbackOpenAiAccount(runtime.account.accountId); + if (fallbackAccount?.apiKey?.trim()) { + return enqueueOpenAiMasterAgentReply({ + accountId: fallbackAccount.accountId, + accountLabel: fallbackAccount.label || aiRoleLabel(fallbackAccount.role), + requestMessageId: params.requestMessageId, + requestText: params.requestText, + requestedBy: params.requestedBy, + requestedByAccount: params.requestedByAccount, + currentSessionExpiresAt: params.currentSessionExpiresAt, + apiKey: fallbackAccount.apiKey, + model: agentControls?.modelOverride || fallbackAccount.model || "gpt-5.4", + reasoningEffort: agentControls?.reasoningEffortOverride || "medium", + agentControls, + }); + } + + await appendMasterAgentSystemReply( + `主 GPT 不在手机里直接登录。当前绑定设备 ${boundNodeLabel}${boundDevice ? " 不在线" : " 未找到"},主 Agent 暂时无法通过这台设备对话。请先在该设备上登录 Codex / ChatGPT Plus,并确保 local-agent 在线后再重试。`, + `主 Agent · ${runtime.summary.roleLabel}`, + ); + return { ok: false as const, reason: "MASTER_NODE_OFFLINE" }; + } + + const task = await queueMasterAgentTask({ + requestMessageId: params.requestMessageId ?? "master-agent-manual", + requestText: params.requestText, + executionPrompt: buildMasterCodexNodePrompt( + state, + params.requestText, + params.currentSessionExpiresAt, + agentControls, + ), + requestedBy: params.requestedBy, + requestedByAccount: params.requestedByAccount, + deviceId, + accountId: runtime.account.accountId, + accountLabel: runtime.account.label || runtime.summary.roleLabel, + }); + + const queuedReply: QueuedMasterAgentReplyEnvelope = { + ok: true as const, + accountId: runtime.account.accountId, + taskId: task.taskId, + masterReplyState: "queued" as const, + task: { + taskId: task.taskId, + taskType: "conversation_reply" as const, + status: "queued" as const, + }, + }; + return queuedReply; + } + + if (runtime.account.provider === "openai_api" && runtime.account.apiKey?.trim()) { + return enqueueOpenAiMasterAgentReply({ + accountId: runtime.account.accountId, + accountLabel: runtime.account.label || runtime.summary.roleLabel, + requestMessageId: params.requestMessageId, + requestText: params.requestText, + requestedBy: params.requestedBy, + requestedByAccount: params.requestedByAccount, + currentSessionExpiresAt: params.currentSessionExpiresAt, + apiKey: runtime.account.apiKey, + model: agentControls?.modelOverride || runtime.account.model || "gpt-5.4", + reasoningEffort: agentControls?.reasoningEffortOverride || "medium", + agentControls, + }); + } + } + if (runtime.account.provider === "master_codex_node") { const state = await readState(); const deviceId = runtime.account.nodeId || state.user.boundDeviceId || "mac-studio"; @@ -1074,6 +1322,7 @@ export async function replyToMasterAgentUserMessage(params: { requestText: params.requestText, currentSessionExpiresAt: params.currentSessionExpiresAt, senderLabel: `主 Agent · ${fallbackAccount.label || aiRoleLabel(fallbackAccount.role)}`, + agentControls, }); } catch { // Fall through to the original offline guidance when the fallback API account cannot respond. @@ -1093,6 +1342,7 @@ export async function replyToMasterAgentUserMessage(params: { state, params.requestText, params.currentSessionExpiresAt, + agentControls, ), requestedBy: params.requestedBy, requestedByAccount: params.requestedByAccount, @@ -1118,6 +1368,7 @@ export async function replyToMasterAgentUserMessage(params: { requestText: params.requestText, currentSessionExpiresAt: params.currentSessionExpiresAt, senderLabel: `主 Agent · ${fallbackAccount.label || aiRoleLabel(fallbackAccount.role)}`, + agentControls, }); } catch { // Preserve the original execution failure below if the fallback account also fails. @@ -1156,9 +1407,11 @@ export async function replyToMasterAgentUserMessage(params: { try { const generated = await generateOpenAiReply({ apiKey: runtime.account.apiKey, - model: runtime.account.model || "gpt-5.4", + model: agentControls?.modelOverride || runtime.account.model || "gpt-5.4", + reasoningEffort: agentControls?.reasoningEffortOverride || "medium", requestText: params.requestText, currentSessionExpiresAt: params.currentSessionExpiresAt, + agentControls, }); await appendMasterAgentSystemReply( diff --git a/tests/master-agent-message-queue.test.ts b/tests/master-agent-message-queue.test.ts new file mode 100644 index 0000000..915c623 --- /dev/null +++ b/tests/master-agent-message-queue.test.ts @@ -0,0 +1,236 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import os from "node:os"; +import path from "node:path"; +import { mkdtemp, rm } from "node:fs/promises"; +import { NextRequest } from "next/server"; + +let runtimeRoot = ""; +let POST: (typeof import("../src/app/api/v1/projects/[projectId]/messages/route"))["POST"]; +let saveAiAccount: (typeof import("../src/lib/boss-data"))["saveAiAccount"]; +let updateProjectAgentControls: (typeof import("../src/lib/boss-data"))["updateProjectAgentControls"]; +let readState: (typeof import("../src/lib/boss-data"))["readState"]; +let createAuthSession: (typeof import("../src/lib/boss-data"))["createAuthSession"]; +let AUTH_SESSION_COOKIE = ""; + +async function setup() { + if (runtimeRoot) { + return; + } + + runtimeRoot = await mkdtemp(path.join(os.tmpdir(), "boss-master-agent-message-queue-")); + process.env.BOSS_RUNTIME_ROOT = runtimeRoot; + process.env.BOSS_STATE_FILE = path.join(runtimeRoot, "boss-state.json"); + + const [messageRoute, data, auth] = await Promise.all([ + import("../src/app/api/v1/projects/[projectId]/messages/route.ts"), + import("../src/lib/boss-data.ts"), + import("../src/lib/boss-auth.ts"), + ]); + + POST = messageRoute.POST; + saveAiAccount = data.saveAiAccount; + updateProjectAgentControls = data.updateProjectAgentControls; + readState = data.readState; + createAuthSession = data.createAuthSession; + AUTH_SESSION_COOKIE = auth.AUTH_SESSION_COOKIE; +} + +async function createAuthedRequest(projectId: string, body: unknown) { + const session = await createAuthSession({ + account: "17600003315", + role: "highest_admin", + displayName: "Boss 超级管理员", + loginMethod: "password", + }); + + return new NextRequest(`http://127.0.0.1:3000/api/v1/projects/${projectId}/messages`, { + method: "POST", + headers: { + "content-type": "application/json", + cookie: `${AUTH_SESSION_COOKIE}=${session.sessionToken}`, + }, + body: JSON.stringify(body), + }); +} + +async function waitFor(predicate: () => Promise, timeoutMs = 5_000) { + const startedAt = Date.now(); + while (Date.now() - startedAt < timeoutMs) { + if (await predicate()) { + return; + } + await new Promise((resolve) => setTimeout(resolve, 50)); + } + throw new Error("waitFor timed out"); +} + +test.after(async () => { + if (runtimeRoot) { + await rm(runtimeRoot, { recursive: true, force: true }); + } +}); + +test("POST /api/v1/projects/master-agent/messages 快速返回队列态并在异步实际回复时继承当前会话覆盖", async () => { + await setup(); + + await saveAiAccount({ + accountId: "openai-master-agent-queue", + label: "API 容灾", + role: "api_fallback", + provider: "openai_api", + displayName: "OpenAI API 队列测试", + model: "gpt-5.4", + apiKey: "sk-test-openai-queue", + enabled: true, + setActive: true, + loginStatusNote: "用于 master-agent 队列测试。", + }); + + await updateProjectAgentControls("master-agent", { + modelOverride: "gpt-4.1-mini", + reasoningEffortOverride: "high", + }); + + const fetchCalls: Array<{ url: string; body: unknown }> = []; + const originalFetch = globalThis.fetch; + globalThis.fetch = (async (input, init) => { + const body = typeof init?.body === "string" ? JSON.parse(init.body) : init?.body ?? null; + fetchCalls.push({ url: String(input), body }); + return new Response(JSON.stringify({ output_text: "已切到异步队列回复。" }), { + status: 200, + headers: { + "content-type": "application/json", + "x-request-id": "req-master-agent-queue", + }, + }); + }) as typeof fetch; + + try { + const response = await POST( + await createAuthedRequest("master-agent", { + body: "请同步 master-agent 当前阻塞点", + }), + { params: Promise.resolve({ projectId: "master-agent" }) }, + ); + + assert.equal(response.status, 200); + + const payload = (await response.json()) as { + ok: boolean; + task?: { taskId: string; taskType: string; status: string } | null; + masterReplyState?: "queued" | "running" | "completed"; + masterReply?: unknown; + }; + + assert.equal(payload.ok, true); + assert.equal(payload.masterReplyState, "queued"); + assert.ok(payload.task, "expected master-agent message to return a task envelope"); + assert.equal(payload.task?.taskType, "conversation_reply"); + assert.equal(payload.task?.status, "queued"); + assert.ok(payload.task?.taskId, "expected a stable taskId in the response"); + + await waitFor(async () => { + const state = await readState(); + const task = state.masterAgentTasks.find((item) => item.taskId === payload.task?.taskId); + return task?.status === "completed"; + }); + + const nextState = await readState(); + const task = nextState.masterAgentTasks.find((item) => item.taskId === payload.task?.taskId); + assert.ok(task, "expected the queued task to remain in state"); + assert.equal(task?.status, "completed"); + assert.equal(task?.replyBody, "已切到异步队列回复。"); + + const masterProject = nextState.projects.find((project) => project.id === "master-agent"); + const mirroredReply = masterProject?.messages.at(-1); + assert.ok(mirroredReply, "expected the async reply to be written back to the master-agent ledger"); + assert.match(mirroredReply?.body ?? "", /已切到异步队列回复/); + + assert.equal(fetchCalls.length, 1); + assert.equal(fetchCalls[0]?.url, "https://api.openai.com/v1/responses"); + const requestBody = fetchCalls[0]?.body as { + model?: string; + reasoning?: { effort?: string }; + }; + assert.equal(requestBody?.model, "gpt-4.1-mini"); + assert.equal(requestBody?.reasoning?.effort, "high"); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test("master-agent enqueue 在主节点离线时会自动切到 OpenAI 后台队列而不是挂到本机设备队列", async () => { + await setup(); + + await saveAiAccount({ + accountId: "master-codex-primary-offline", + label: "主 GPT", + role: "primary", + provider: "master_codex_node", + displayName: "离线 Master Codex Node", + nodeId: "offline-node", + nodeLabel: "离线节点", + model: "gpt-5.4", + enabled: true, + setActive: true, + loginStatusNote: "离线主节点", + }); + + await saveAiAccount({ + accountId: "openai-backup-queue", + label: "备用 GPT", + role: "backup", + provider: "openai_api", + displayName: "OpenAI 备用账号", + accountIdentifier: "sk-queue-demo", + model: "gpt-5.4", + apiKey: "sk-queue-demo", + enabled: true, + setActive: false, + loginStatusNote: "备用 API 账号", + }); + + const originalFetch = globalThis.fetch; + globalThis.fetch = (async () => + new Response(JSON.stringify({ output_text: "离线主节点已切到 API 后台队列。" }), { + status: 200, + headers: { + "content-type": "application/json", + "x-request-id": "req-master-agent-offline-fallback-queue", + }, + })) as typeof fetch; + + try { + const response = await POST( + await createAuthedRequest("master-agent", { + body: "请走备用 API 队列", + }), + { params: Promise.resolve({ projectId: "master-agent" }) }, + ); + + assert.equal(response.status, 200); + const payload = (await response.json()) as { + ok: boolean; + task?: { taskId: string; taskType: string; status: string } | null; + masterReplyState?: "queued" | "running" | "completed"; + }; + assert.equal(payload.ok, true); + assert.equal(payload.masterReplyState, "queued"); + assert.equal(payload.task?.taskType, "conversation_reply"); + + await waitFor(async () => { + const state = await readState(); + const task = state.masterAgentTasks.find((item) => item.taskId === payload.task?.taskId); + return task?.status === "completed"; + }); + + const nextState = await readState(); + const task = nextState.masterAgentTasks.find((item) => item.taskId === payload.task?.taskId); + assert.equal(task?.deviceId, "master-agent-openai"); + assert.equal(task?.status, "completed"); + assert.equal(task?.accountId, "openai-backup-queue"); + } finally { + globalThis.fetch = originalFetch; + } +});