From 39be49630f7068daa1c92db5e5c9b00399f61da4 Mon Sep 17 00:00:00 2001 From: kris Date: Thu, 16 Apr 2026 04:41:46 +0800 Subject: [PATCH] Integrate master agent runtime orchestration updates --- .../java/com/hyzq/boss/BossApiClient.java | 78 +- .../src/main/java/com/hyzq/boss/BossUi.java | 194 ++++ .../hyzq/boss/ConversationInfoActivity.java | 23 +- .../com/hyzq/boss/GroupCreateActivity.java | 25 +- .../java/com/hyzq/boss/GroupInfoActivity.java | 17 +- .../com/hyzq/boss/ProjectChatUiState.java | 105 ++- .../com/hyzq/boss/ProjectDetailActivity.java | 569 ++++++++++-- .../java/com/hyzq/boss/BossApiClientTest.java | 77 ++ .../com/hyzq/boss/BossUiRootSurfaceTest.java | 9 +- .../boss/ConversationFolderActivityTest.java | 4 +- ...inActivityConversationAutoRefreshTest.java | 12 + .../hyzq/boss/MainActivityRealtimeTest.java | 77 +- .../ProjectDetailActivityRealtimeTest.java | 39 +- .../boss/ProjectDetailActivityUiTest.java | 4 +- docs/architecture/ai_handoff_index_cn.md | 1 + .../api_and_service_inventory_cn.md | 36 +- .../current_runtime_and_deploy_status_cn.md | 9 +- .../hermes_runtime_接入与运维指南_cn.md | 472 ++++++++++ .../hermes_对_boss_主agent长期融合评估_cn.md | 772 ++++++++++++++++ ...2026-04-14-android-chat-status-row-plan.md | 112 +++ .../plans/2026-04-14-hermes-backend-mvp.md | 318 +++++++ ...26-04-14-android-chat-status-row-design.md | 36 + .../2026-04-14-hermes-backend-mvp-design.md | 271 ++++++ ...026-04-16-master-agent-fast-path-design.md | 101 ++ local-agent/codex-task-runner.mjs | 12 +- scripts/hermes-runtime-smoke.mjs | 20 + .../tasks/[taskId]/complete/route.ts | 5 + .../[projectId]/agent-controls/route.ts | 138 ++- .../v1/projects/[projectId]/messages/route.ts | 11 +- .../[projectId]/participants/route.ts | 138 +-- .../[projectId]/prompt-profile/route.ts | 28 +- src/app/conversations/[projectId]/page.tsx | 165 +++- .../[projectId]/participants/page.tsx | 150 +++ .../[projectId]/thread-status/page.tsx | 2 +- src/app/me/master-agent/page.tsx | 5 +- src/app/me/master-agent/takeover/page.tsx | 2 +- src/components/ai-accounts-client.tsx | 2 +- src/components/app-runtime.tsx | 2 +- src/components/app-ui.tsx | 10 +- .../group-participants-repair-client.tsx | 115 +++ .../master-agent-prompt-memory-client.tsx | 111 ++- src/lib/boss-data.ts | 377 +++++++- src/lib/boss-master-agent.ts | 870 +++++++++++++++++- src/lib/boss-projections-shared.ts | 219 +++++ src/lib/boss-projections.ts | 217 +++-- src/lib/execution/backend-selector.ts | 22 + src/lib/execution/backends/hermes-backend.ts | 112 +++ src/lib/execution/backends/hermes-config.ts | 212 +++++ src/lib/execution/backends/hermes-runner.ts | 152 +++ src/lib/execution/memory-resolver.ts | 67 +- src/lib/execution/remote-runtime-adapter.ts | 26 + src/lib/execution/types.ts | 1 + ...d-chat-incremental-realtime-append.test.ts | 75 ++ .../android-chat-local-realtime-patch.test.ts | 71 ++ ...ndroid-detail-contract-unification.test.ts | 122 +++ tests/android-dispatch-reply-wait.test.ts | 52 ++ tests/device-import-draft.test.ts | 25 + tests/dispatch-plan-confirmation.test.ts | 233 ++++- tests/execution-backend-selector.test.ts | 59 ++ tests/execution-foundation-contracts.test.ts | 1 + tests/execution-memory-resolver.test.ts | 68 ++ tests/group-message-dispatch-plan.test.ts | 25 + tests/group-participants-repair.test.ts | 73 +- tests/hermes-backend-config.test.ts | 127 +++ tests/hermes-backend.test.ts | 131 +++ tests/hermes-runner.test.ts | 157 ++++ tests/local-agent-codex-task-runner.test.mjs | 32 + tests/master-agent-chat-controls.test.ts | 262 ++++++ tests/master-agent-config-resolution.test.ts | 56 ++ tests/master-agent-message-queue.test.ts | 328 +++++++ ...master-agent-prompts-memory-routes.test.ts | 49 +- .../master-agent-prompts-memory-state.test.ts | 43 + .../project-chat-page-realtime-events.test.ts | 65 ++ tests/project-detail-route.test.ts | 260 ++++++ tests/project-header-actions.test.ts | 28 + tests/project-messages-route.test.ts | 224 +++++ tests/project-scoped-realtime-refresh.test.ts | 4 +- tests/remote-runtime-adapter.test.ts | 24 + tests/single-thread-message-execution.test.ts | 438 ++++++++- tests/web-group-participants-page.test.ts | 53 ++ ...b-group-participants-repair-client.test.ts | 94 ++ 81 files changed, 9283 insertions(+), 448 deletions(-) create mode 100644 android/app/src/test/java/com/hyzq/boss/BossApiClientTest.java create mode 100644 docs/architecture/hermes_runtime_接入与运维指南_cn.md create mode 100644 docs/architecture/hermes_对_boss_主agent长期融合评估_cn.md create mode 100644 docs/superpowers/plans/2026-04-14-android-chat-status-row-plan.md create mode 100644 docs/superpowers/plans/2026-04-14-hermes-backend-mvp.md create mode 100644 docs/superpowers/specs/2026-04-14-android-chat-status-row-design.md create mode 100644 docs/superpowers/specs/2026-04-14-hermes-backend-mvp-design.md create mode 100644 docs/superpowers/specs/2026-04-16-master-agent-fast-path-design.md create mode 100644 scripts/hermes-runtime-smoke.mjs create mode 100644 src/app/conversations/[projectId]/participants/page.tsx create mode 100644 src/components/group-participants-repair-client.tsx create mode 100644 src/lib/boss-projections-shared.ts create mode 100644 src/lib/execution/backends/hermes-backend.ts create mode 100644 src/lib/execution/backends/hermes-config.ts create mode 100644 src/lib/execution/backends/hermes-runner.ts create mode 100644 tests/android-detail-contract-unification.test.ts create mode 100644 tests/android-dispatch-reply-wait.test.ts create mode 100644 tests/hermes-backend-config.test.ts create mode 100644 tests/hermes-backend.test.ts create mode 100644 tests/hermes-runner.test.ts create mode 100644 tests/project-detail-route.test.ts create mode 100644 tests/project-header-actions.test.ts create mode 100644 tests/web-group-participants-page.test.ts create mode 100644 tests/web-group-participants-repair-client.test.ts 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 7746094..0f8c94e 100644 --- a/android/app/src/main/java/com/hyzq/boss/BossApiClient.java +++ b/android/app/src/main/java/com/hyzq/boss/BossApiClient.java @@ -119,6 +119,10 @@ public class BossApiClient { return requestWithRestore("GET", "/api/v1/projects/" + encode(projectId) + "/dispatch-plans", null); } + public ApiResponse getConversationParticipants(String projectId) throws IOException, JSONException { + return requestWithRestore("GET", "/api/v1/projects/" + encode(projectId) + "/participants", null); + } + public ApiResponse getProjectAgentControls(String projectId) throws IOException, JSONException { return requestWithRestore("GET", "/api/v1/projects/" + encode(projectId) + "/agent-controls", null); } @@ -134,6 +138,76 @@ public class BossApiClient { return requestWithRestore("POST", "/api/v1/projects/" + encode(projectId) + "/agent-controls", payload); } + public ApiResponse updateProjectAgentControls( + String projectId, + @Nullable String modelOverride, + @Nullable String reasoningEffortOverride, + @Nullable String fastModelOverride, + @Nullable String fastReasoningEffortOverride, + @Nullable String smartModelOverride, + @Nullable String smartReasoningEffortOverride + ) throws IOException, JSONException { + JSONObject payload = buildProjectAgentControlsPayload( + modelOverride, + reasoningEffortOverride, + fastModelOverride, + fastReasoningEffortOverride, + smartModelOverride, + smartReasoningEffortOverride, + false + ); + return requestWithRestore("POST", "/api/v1/projects/" + encode(projectId) + "/agent-controls", payload); + } + + public ApiResponse updateProjectAgentControls( + String projectId, + @Nullable String modelOverride, + @Nullable String reasoningEffortOverride, + @Nullable String fastModelOverride, + @Nullable String fastReasoningEffortOverride, + @Nullable String smartModelOverride, + @Nullable String smartReasoningEffortOverride, + boolean includeAdvancedOverrides + ) throws IOException, JSONException { + JSONObject payload = buildProjectAgentControlsPayload( + modelOverride, + reasoningEffortOverride, + fastModelOverride, + fastReasoningEffortOverride, + smartModelOverride, + smartReasoningEffortOverride, + includeAdvancedOverrides + ); + return requestWithRestore("POST", "/api/v1/projects/" + encode(projectId) + "/agent-controls", payload); + } + + static JSONObject buildProjectAgentControlsPayload( + @Nullable String modelOverride, + @Nullable String reasoningEffortOverride, + @Nullable String fastModelOverride, + @Nullable String fastReasoningEffortOverride, + @Nullable String smartModelOverride, + @Nullable String smartReasoningEffortOverride, + boolean includeAdvancedOverrides + ) throws JSONException { + JSONObject payload = new JSONObject(); + payload.put("modelOverride", modelOverride == null ? JSONObject.NULL : modelOverride); + payload.put("reasoningEffortOverride", reasoningEffortOverride == null ? JSONObject.NULL : reasoningEffortOverride); + + boolean hasAdvancedOverrides = + fastModelOverride != null + || fastReasoningEffortOverride != null + || smartModelOverride != null + || smartReasoningEffortOverride != null; + if (includeAdvancedOverrides || hasAdvancedOverrides) { + payload.put("fastModelOverride", fastModelOverride == null ? JSONObject.NULL : fastModelOverride); + payload.put("fastReasoningEffortOverride", fastReasoningEffortOverride == null ? JSONObject.NULL : fastReasoningEffortOverride); + payload.put("smartModelOverride", smartModelOverride == null ? JSONObject.NULL : smartModelOverride); + payload.put("smartReasoningEffortOverride", smartReasoningEffortOverride == null ? JSONObject.NULL : smartReasoningEffortOverride); + } + return payload; + } + public ApiResponse updateProjectAgentControls( String projectId, @Nullable String modelOverride, @@ -263,10 +337,6 @@ public class BossApiClient { return requestWithRestore("POST", "/api/v1/group-chats", payload == null ? new JSONObject() : payload); } - public ApiResponse getConversationParticipants(String projectId) throws IOException, JSONException { - return requestWithRestore("GET", "/api/v1/projects/" + encode(projectId) + "/participants", null); - } - public ApiResponse getThreadStatus(String projectId) throws IOException, JSONException { return requestWithRestore("GET", "/api/v1/projects/" + encode(projectId) + "/thread-status", null); } diff --git a/android/app/src/main/java/com/hyzq/boss/BossUi.java b/android/app/src/main/java/com/hyzq/boss/BossUi.java index b14b8f5..82aa569 100644 --- a/android/app/src/main/java/com/hyzq/boss/BossUi.java +++ b/android/app/src/main/java/com/hyzq/boss/BossUi.java @@ -26,6 +26,11 @@ import androidx.annotation.Nullable; import androidx.core.widget.ImageViewCompat; import android.content.res.ColorStateList; +import org.json.JSONObject; + +import java.util.ArrayList; +import java.util.List; + public final class BossUi { private static final int[] AVATAR_BG_COLORS = { Color.parseColor("#1EC76F"), @@ -1229,6 +1234,195 @@ public final class BossUi { return buildMessageBubble(context, effectiveSender, body, "发送中", true, null); } + public static LinearLayout buildExecutionWarningCard( + Context context, + String title, + @Nullable String summary, + boolean outgoing + ) { + LinearLayout card = new LinearLayout(context); + card.setOrientation(LinearLayout.VERTICAL); + LinearLayout.LayoutParams params = new LinearLayout.LayoutParams( + LinearLayout.LayoutParams.WRAP_CONTENT, + LinearLayout.LayoutParams.WRAP_CONTENT + ); + params.topMargin = dp(context, 8); + card.setLayoutParams(params); + card.setPadding(dp(context, 12), dp(context, 10), dp(context, 12), dp(context, 10)); + card.setBackground(createRoundedBackground( + outgoing ? Color.parseColor("#FFF1D7") : Color.parseColor("#FFF7E7"), + dp(context, 14) + )); + + TextView titleView = new TextView(context); + titleView.setText(TextUtils.isEmpty(title) ? "执行提醒" : title); + titleView.setTextSize(12); + titleView.setTypeface(Typeface.DEFAULT_BOLD); + titleView.setTextColor(Color.parseColor("#B36B00")); + card.addView(titleView); + + if (!TextUtils.isEmpty(summary)) { + TextView summaryView = new TextView(context); + summaryView.setText(summary); + summaryView.setTextSize(13); + summaryView.setLineSpacing(0f, 1.2f); + summaryView.setTextColor(context.getColor(R.color.boss_text_primary)); + summaryView.setPadding(0, dp(context, 4), 0, 0); + summaryView.setMaxWidth(Math.round(context.getResources().getDisplayMetrics().widthPixels * 0.62f)); + summaryView.setBreakStrategy(Layout.BREAK_STRATEGY_HIGH_QUALITY); + summaryView.setHyphenationFrequency(Layout.HYPHENATION_FREQUENCY_NORMAL); + card.addView(summaryView); + } + + return card; + } + + public static LinearLayout buildMessageStatusRow( + Context context, + JSONObject message, + @Nullable JSONObject conversationTask, + List warnings, + boolean outgoing + ) { + LinearLayout row = new LinearLayout(context); + row.setOrientation(LinearLayout.VERTICAL); + LinearLayout.LayoutParams rowParams = new LinearLayout.LayoutParams( + LinearLayout.LayoutParams.WRAP_CONTENT, + LinearLayout.LayoutParams.WRAP_CONTENT + ); + rowParams.topMargin = dp(context, 8); + row.setLayoutParams(rowParams); + + boolean hasTask = conversationTask != null; + boolean hasWarnings = warnings != null && !warnings.isEmpty(); + String detailText = buildStatusDetailText(message, conversationTask, warnings); + boolean hasDetail = !TextUtils.isEmpty(detailText); + if (!hasTask && !hasWarnings && !hasDetail) { + row.setVisibility(View.GONE); + return row; + } + + if (hasTask || hasWarnings) { + LinearLayout pillRow = new LinearLayout(context); + pillRow.setOrientation(LinearLayout.HORIZONTAL); + pillRow.setGravity(Gravity.CENTER_VERTICAL); + row.addView(pillRow); + if (hasTask) { + pillRow.addView(buildStatusPill( + context, + taskStatusLabel(conversationTask.optString("status", "")), + outgoing ? Color.parseColor("#D7F3DF") : Color.parseColor("#EDF7F0"), + Color.parseColor("#215B39") + )); + } + if (hasWarnings) { + if (pillRow.getChildCount() > 0) { + View spacer = new View(context); + LinearLayout.LayoutParams spacerParams = new LinearLayout.LayoutParams(dp(context, 6), 1); + spacer.setLayoutParams(spacerParams); + pillRow.addView(spacer); + } + pillRow.addView(buildStatusPill( + context, + warningBadgeLabel(warnings), + outgoing ? Color.parseColor("#FFF1D7") : Color.parseColor("#FFF7E7"), + Color.parseColor("#B36B00") + )); + } + } + + if (hasDetail) { + TextView detailView = new TextView(context); + detailView.setText(detailText); + detailView.setTextSize(12); + detailView.setTextColor(context.getColor(R.color.boss_text_muted)); + detailView.setPadding(dp(context, 2), dp(context, 4), dp(context, 2), 0); + detailView.setMaxLines(2); + detailView.setEllipsize(TextUtils.TruncateAt.END); + detailView.setMaxWidth(Math.round(context.getResources().getDisplayMetrics().widthPixels * 0.62f)); + row.addView(detailView); + } + + return row; + } + + private static TextView buildStatusPill( + Context context, + String text, + int backgroundColor, + int textColor + ) { + TextView pill = new TextView(context); + pill.setText(text); + pill.setTextSize(11); + pill.setTypeface(Typeface.DEFAULT_BOLD); + pill.setTextColor(textColor); + pill.setPadding(dp(context, 10), dp(context, 5), dp(context, 10), dp(context, 5)); + pill.setBackground(createRoundedBackground(backgroundColor, dp(context, 12))); + return pill; + } + + private static String taskStatusLabel(String status) { + switch (status) { + case "queued": + return "排队中"; + case "running": + return "执行中"; + case "completed": + return "已完成"; + case "failed": + return "已失败"; + default: + return TextUtils.isEmpty(status) ? "处理中" : status; + } + } + + private static String warningBadgeLabel(List warnings) { + int totalWarnings = 0; + for (JSONObject warning : warnings) { + if (warning == null) { + continue; + } + totalWarnings += Math.max(1, warning.optInt("count", 1)); + } + return totalWarnings > 1 ? "提醒 " + totalWarnings : "执行提醒"; + } + + private static String buildStatusDetailText( + JSONObject message, + @Nullable JSONObject conversationTask, + List warnings + ) { + ArrayList parts = new ArrayList<>(); + if (conversationTask != null) { + String sessionId = conversationTask.optString("sessionId", ""); + String taskId = conversationTask.optString("taskId", ""); + if (!TextUtils.isEmpty(sessionId)) { + parts.add("Session " + sessionId); + } else if (!TextUtils.isEmpty(taskId)) { + parts.add("Task " + taskId); + } + } + if (warnings != null && !warnings.isEmpty()) { + JSONObject firstWarning = warnings.get(0); + if (firstWarning != null) { + String summary = firstWarning.optString("summary", ""); + String title = firstWarning.optString("title", "执行提醒"); + int count = Math.max(1, firstWarning.optInt("count", 1)); + String warningText = !TextUtils.isEmpty(summary) ? summary : title; + if (count > 1) { + warningText = warningText + " 等 " + count + " 条"; + } else if (warnings.size() > 1) { + warningText = warningText + " 等 " + warnings.size() + " 组"; + } + parts.add(warningText); + } + } else if ("system_notice".equals(message.optString("kind", ""))) { + parts.add("系统同步提醒"); + } + return TextUtils.join(" · ", parts); + } + public static void applyMessageSelectionState(Context context, View messageView, boolean selected) { if (messageView == null) { return; diff --git a/android/app/src/main/java/com/hyzq/boss/ConversationInfoActivity.java b/android/app/src/main/java/com/hyzq/boss/ConversationInfoActivity.java index b8efb73..42f950b 100644 --- a/android/app/src/main/java/com/hyzq/boss/ConversationInfoActivity.java +++ b/android/app/src/main/java/com/hyzq/boss/ConversationInfoActivity.java @@ -76,7 +76,7 @@ public class ConversationInfoActivity extends BossScreenActivity { try { LoadedConversation loadedConversation = loadConversation(); BossApiClient.ApiResponse detailResponse = loadedConversation.detailResponse; - BossApiClient.ApiResponse participantsResponse = loadedConversation.participantsResponse; + JSONObject participantsPayload = loadedConversation.participantsPayload; JSONObject threadStatusPayload = null; try { BossApiClient.ApiResponse threadStatusResponse = loadedConversation.threadStatusResponse; @@ -87,7 +87,7 @@ public class ConversationInfoActivity extends BossScreenActivity { threadStatusPayload = null; } JSONObject finalThreadStatusPayload = threadStatusPayload; - runOnUiThread(() -> renderConversation(detailResponse.json, participantsResponse.json, finalThreadStatusPayload)); + runOnUiThread(() -> renderConversation(detailResponse.json, participantsPayload, finalThreadStatusPayload)); } catch (Exception error) { runOnUiThread(() -> { setRefreshing(false); @@ -471,13 +471,14 @@ public class ConversationInfoActivity extends BossScreenActivity { throw new IllegalStateException(detailResponse.message()); } - BossApiClient.ApiResponse participantsResponse = apiClient.getConversationParticipants(projectId); - if (!participantsResponse.ok()) { - throw new IllegalStateException(participantsResponse.message()); - } - + JSONObject participantsPayload = extractParticipantsPayload(detailResponse.json); BossApiClient.ApiResponse threadStatusResponse = apiClient.getThreadStatus(projectId); - return new LoadedConversation(detailResponse, participantsResponse, threadStatusResponse); + return new LoadedConversation(detailResponse, participantsPayload, threadStatusResponse); + } + + private JSONObject extractParticipantsPayload(JSONObject detailPayload) { + JSONObject participantsPayload = detailPayload == null ? null : detailPayload.optJSONObject("participantsPayload"); + return participantsPayload == null ? new JSONObject() : participantsPayload; } private BossApiClient.ApiResponse saveTakeoverSettingsWithRetry( @@ -548,16 +549,16 @@ public class ConversationInfoActivity extends BossScreenActivity { private static final class LoadedConversation { private final BossApiClient.ApiResponse detailResponse; - private final BossApiClient.ApiResponse participantsResponse; + private final JSONObject participantsPayload; private final BossApiClient.ApiResponse threadStatusResponse; private LoadedConversation( BossApiClient.ApiResponse detailResponse, - BossApiClient.ApiResponse participantsResponse, + JSONObject participantsPayload, BossApiClient.ApiResponse threadStatusResponse ) { this.detailResponse = detailResponse; - this.participantsResponse = participantsResponse; + this.participantsPayload = participantsPayload; this.threadStatusResponse = threadStatusResponse; } } diff --git a/android/app/src/main/java/com/hyzq/boss/GroupCreateActivity.java b/android/app/src/main/java/com/hyzq/boss/GroupCreateActivity.java index e5503b4..ecb721c 100644 --- a/android/app/src/main/java/com/hyzq/boss/GroupCreateActivity.java +++ b/android/app/src/main/java/com/hyzq/boss/GroupCreateActivity.java @@ -11,6 +11,7 @@ import android.widget.TextView; import androidx.annotation.Nullable; import org.json.JSONArray; +import org.json.JSONException; import org.json.JSONObject; import java.util.ArrayList; @@ -70,9 +71,10 @@ public class GroupCreateActivity extends BossScreenActivity { runOnUiThread(() -> renderCreatePage(null, conversationsResponse.json, true)); return; } - BossApiClient.ApiResponse participantsResponse = apiClient.getConversationParticipants(sourceProjectId); - if (!participantsResponse.ok()) throw new IllegalStateException(participantsResponse.message()); - runOnUiThread(() -> renderCreatePage(participantsResponse.json, conversationsResponse.json, true)); + BossApiClient.ApiResponse detailResponse = apiClient.getProjectDetail(sourceProjectId); + if (!detailResponse.ok()) throw new IllegalStateException(detailResponse.message()); + JSONObject participantsPayload = extractParticipantsPayload(detailResponse.json, sourceProjectId); + runOnUiThread(() -> renderCreatePage(participantsPayload, conversationsResponse.json, true)); } catch (Exception error) { runOnUiThread(() -> { setRefreshing(false); @@ -155,6 +157,23 @@ public class GroupCreateActivity extends BossScreenActivity { updateCreateButtonState(); } + private JSONObject extractParticipantsPayload(JSONObject detailPayload, String fallbackProjectId) { + JSONObject participantsPayload = detailPayload == null ? null : detailPayload.optJSONObject("participantsPayload"); + if (participantsPayload != null) { + return participantsPayload; + } + JSONObject fallback = new JSONObject(); + JSONObject project = detailPayload == null ? null : detailPayload.optJSONObject("project"); + JSONObject threadMeta = project == null ? null : project.optJSONObject("threadMeta"); + try { + fallback.put("projectId", fallbackProjectId == null ? "" : fallbackProjectId); + fallback.put("threadMeta", threadMeta == null ? new JSONObject() : threadMeta); + fallback.put("participants", new JSONArray()); + } catch (JSONException ignored) { + } + return fallback; + } + private View buildHeaderView( boolean hasSourceProject, @Nullable String sourceProjectId, diff --git a/android/app/src/main/java/com/hyzq/boss/GroupInfoActivity.java b/android/app/src/main/java/com/hyzq/boss/GroupInfoActivity.java index dbaf6e8..ebb9708 100644 --- a/android/app/src/main/java/com/hyzq/boss/GroupInfoActivity.java +++ b/android/app/src/main/java/com/hyzq/boss/GroupInfoActivity.java @@ -22,6 +22,7 @@ public class GroupInfoActivity extends BossScreenActivity { private String projectId; private String projectName; + private boolean groupRepairJustApplied; private @Nullable BossRealtimeClient realtimeClient; private final Map recentRealtimeEventTimestamps = new LinkedHashMap<>(); @@ -72,13 +73,12 @@ public class GroupInfoActivity extends BossScreenActivity { try { BossApiClient.ApiResponse detailResponse = apiClient.getProjectDetail(projectId); if (!detailResponse.ok()) throw new IllegalStateException(detailResponse.message()); - BossApiClient.ApiResponse participantsResponse = apiClient.getConversationParticipants(projectId); - if (!participantsResponse.ok()) throw new IllegalStateException(participantsResponse.message()); + JSONObject participantsPayload = extractParticipantsPayload(detailResponse.json); BossApiClient.ApiResponse orchestrationResponse = apiClient.getProjectOrchestrationBackend(projectId); JSONObject orchestrationBackend = orchestrationResponse.ok() ? orchestrationResponse.json : buildFallbackOrchestrationBackendPayload(orchestrationResponse.message()); - runOnUiThread(() -> renderGroup(detailResponse.json, participantsResponse.json, orchestrationBackend)); + runOnUiThread(() -> renderGroup(detailResponse.json, participantsPayload, orchestrationBackend)); } catch (Exception error) { runOnUiThread(() -> { setRefreshing(false); @@ -149,6 +149,11 @@ public class GroupInfoActivity extends BossScreenActivity { } } + private JSONObject extractParticipantsPayload(JSONObject detailPayload) { + JSONObject participantsPayload = detailPayload == null ? null : detailPayload.optJSONObject("participantsPayload"); + return participantsPayload == null ? new JSONObject() : participantsPayload; + } + private void renderGroup(JSONObject detail, JSONObject participantsPayload) { renderGroup(detail, participantsPayload, null); } @@ -174,6 +179,11 @@ public class GroupInfoActivity extends BossScreenActivity { int invalidParticipantCount = participantsPayload.optInt("invalidParticipantCount", 0); configureScreen("群资料", buildSubtitle(folderName, participantCount)); + if (groupRepairJustApplied) { + appendContent(BossUi.buildEmptyCard(this, "群成员已更新,当前群聊已经切换到新的真实线程成员。")); + groupRepairJustApplied = false; + } + appendContent(BossUi.buildSimpleProfileHeader( this, projectName, @@ -367,6 +377,7 @@ public class GroupInfoActivity extends BossScreenActivity { BossApiClient.ApiResponse response = apiClient.replaceConversationParticipants(projectId, memberProjectIds); if (!response.ok()) throw new IllegalStateException(response.message()); runOnUiThread(() -> { + groupRepairJustApplied = true; showMessage("群成员已更新"); reload(); }); diff --git a/android/app/src/main/java/com/hyzq/boss/ProjectChatUiState.java b/android/app/src/main/java/com/hyzq/boss/ProjectChatUiState.java index 73e8dea..ef7d689 100644 --- a/android/app/src/main/java/com/hyzq/boss/ProjectChatUiState.java +++ b/android/app/src/main/java/com/hyzq/boss/ProjectChatUiState.java @@ -62,10 +62,26 @@ public final class ProjectChatUiState { public static final class ReplyWaitSpec { public final boolean shouldWait; public final String baselineMessageId; + public final List executionIds; - private ReplyWaitSpec(boolean shouldWait, @Nullable String baselineMessageId) { - this.shouldWait = shouldWait && !isBlank(baselineMessageId); - this.baselineMessageId = this.shouldWait ? baselineMessageId.trim() : ""; + private ReplyWaitSpec( + boolean shouldWait, + @Nullable String baselineMessageId, + @Nullable List executionIds + ) { + ArrayList normalizedExecutionIds = new ArrayList<>(); + if (executionIds != null) { + for (String executionId : executionIds) { + if (!isBlank(executionId)) { + normalizedExecutionIds.add(executionId.trim()); + } + } + } + boolean hasBaseline = !isBlank(baselineMessageId); + boolean hasExecutionIds = !normalizedExecutionIds.isEmpty(); + this.shouldWait = shouldWait && (hasBaseline || hasExecutionIds); + this.baselineMessageId = hasBaseline ? baselineMessageId.trim() : ""; + this.executionIds = Collections.unmodifiableList(normalizedExecutionIds); } } @@ -410,30 +426,81 @@ public final class ProjectChatUiState { public static ReplyWaitSpec resolveReplyWaitAfterSend(@Nullable JSONObject response) { if (response == null) { - return new ReplyWaitSpec(false, null); + return new ReplyWaitSpec(false, null, null); } JSONObject task = response.optJSONObject("task"); if (task == null) { - return new ReplyWaitSpec(false, null); + return new ReplyWaitSpec(false, null, null); } String taskStatus = task.optString("status", ""); if ("completed".equals(taskStatus) || "failed".equals(taskStatus)) { - return new ReplyWaitSpec(false, null); + return new ReplyWaitSpec(false, null, null); } JSONObject message = response.optJSONObject("message"); - return new ReplyWaitSpec(true, message == null ? null : message.optString("id", "")); + return new ReplyWaitSpec(true, message == null ? null : message.optString("id", ""), null); } public static ReplyWaitSpec resolveReplyWaitAfterDispatchConfirm(@Nullable JSONObject response) { if (response == null) { - return new ReplyWaitSpec(false, null); + return new ReplyWaitSpec(false, null, null); } JSONArray executions = response.optJSONArray("executions"); if (executions == null || executions.length() == 0) { - return new ReplyWaitSpec(false, null); + return new ReplyWaitSpec(false, null, null); } JSONObject notice = response.optJSONObject("notice"); - return new ReplyWaitSpec(true, notice == null ? null : notice.optString("id", "")); + return new ReplyWaitSpec( + true, + notice == null ? null : notice.optString("id", ""), + collectExecutionIds(executions) + ); + } + + public static ReplyWaitSpec replyWaitFromBaseline(@Nullable String baselineMessageId) { + return new ReplyWaitSpec(true, baselineMessageId, null); + } + + public static boolean hasTrackedDispatchExecutionReply( + @Nullable JSONArray dispatchPlans, + @Nullable List executionIds + ) { + if (dispatchPlans == null || executionIds == null || executionIds.isEmpty()) { + return false; + } + LinkedHashSet trackedIds = new LinkedHashSet<>(); + for (String executionId : executionIds) { + if (!isBlank(executionId)) { + trackedIds.add(executionId.trim()); + } + } + if (trackedIds.isEmpty()) { + return false; + } + for (int i = 0; i < dispatchPlans.length(); i++) { + JSONObject plan = dispatchPlans.optJSONObject(i); + if (plan == null) { + continue; + } + JSONArray executions = plan.optJSONArray("executions"); + if (executions == null) { + continue; + } + for (int j = 0; j < executions.length(); j++) { + JSONObject execution = executions.optJSONObject(j); + if (execution == null) { + continue; + } + String executionId = execution.optString("executionId", "").trim(); + if (!trackedIds.contains(executionId)) { + continue; + } + String status = execution.optString("status", "").trim(); + if ("completed".equals(status) || "failed".equals(status)) { + return true; + } + } + } + return false; } public static boolean hasReplyBeyondBaseline(@Nullable JSONObject project, @Nullable String baselineMessageId) { @@ -457,6 +524,24 @@ public final class ProjectChatUiState { return messageId.isEmpty() ? null : messageId; } + private static List collectExecutionIds(@Nullable JSONArray executions) { + ArrayList executionIds = new ArrayList<>(); + if (executions == null) { + return executionIds; + } + for (int i = 0; i < executions.length(); i++) { + JSONObject execution = executions.optJSONObject(i); + if (execution == null) { + continue; + } + String executionId = execution.optString("executionId", "").trim(); + if (!executionId.isEmpty()) { + executionIds.add(executionId); + } + } + return executionIds; + } + private static boolean isBlank(@Nullable String value) { return value == null || value.trim().isEmpty(); } 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 a2a5eac..cdb9b3a 100644 --- a/android/app/src/main/java/com/hyzq/boss/ProjectDetailActivity.java +++ b/android/app/src/main/java/com/hyzq/boss/ProjectDetailActivity.java @@ -36,6 +36,7 @@ import java.io.File; import java.io.FileOutputStream; import java.io.InputStream; import java.util.ArrayList; +import java.util.LinkedHashMap; import java.util.List; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; @@ -55,6 +56,12 @@ public class ProjectDetailActivity extends BossScreenActivity { private String projectFolderName; private @Nullable String currentAgentModelOverride; private @Nullable String currentReasoningEffortOverride; + private @Nullable String currentFastModelOverride; + private @Nullable String currentFastReasoningEffortOverride; + private @Nullable String currentSmartModelOverride; + private @Nullable String currentSmartReasoningEffortOverride; + private final List currentAvailableMasterAgentModels = new ArrayList<>(); + private final List currentSelectableMasterAgentModels = new ArrayList<>(); private LinearLayout quickActionsLayout; private LinearLayout composerRow; private LinearLayout multiSelectActionsLayout; @@ -174,6 +181,25 @@ public class ProjectDetailActivity extends BossScreenActivity { } } + static @Nullable JSONArray extractDispatchPlans(JSONObject detailPayload) { + return detailPayload == null ? null : detailPayload.optJSONArray("dispatchPlans"); + } + + static @Nullable JSONObject extractParticipantsPayload(JSONObject detailPayload) { + return detailPayload == null ? null : detailPayload.optJSONObject("participantsPayload"); + } + + private String buildDisplayedProjectTitle(@Nullable String rawTitle) { + String normalizedTitle = TextUtils.isEmpty(rawTitle) ? "项目详情" : rawTitle; + if (!isMasterAgentConversation()) { + return normalizedTitle; + } + String modelName = !TextUtils.isEmpty(currentAgentModelOverride) + ? currentAgentModelOverride + : (!TextUtils.isEmpty(currentSmartModelOverride) ? currentSmartModelOverride : null); + return TextUtils.isEmpty(modelName) ? "主Agent" : "主Agent·" + modelName; + } + @Override protected int getLayoutResId() { return R.layout.activity_project_chat; @@ -273,7 +299,7 @@ public class ProjectDetailActivity extends BossScreenActivity { BossWindowInsets.applyKeyboardAvoidingInset(composerRow); BossWindowInsets.applyKeyboardAvoidingInset(multiSelectActionsLayout); - updateProjectHeader(initialProjectName == null ? "项目详情" : initialProjectName, "正在同步项目详情..."); + updateProjectHeader(buildDisplayedProjectTitle(initialProjectName), "正在同步项目详情..."); if (composerAttachmentButton != null) { composerAttachmentButton.setOnClickListener(v -> showAttachmentEntrySheet()); } @@ -371,12 +397,19 @@ public class ProjectDetailActivity extends BossScreenActivity { if (projectMessagesPayload == null) { return false; } + if (shouldBypassRealtimeMessagesPatchForGroupState()) { + return false; + } + JSONArray executionWarnings = projectMessagesPayload.optJSONArray("executionWarnings"); if (trySkipUnchangedRealtimeMessagesPatch(projectMessagesPayload)) { return true; } if (tryAppendRealtimeMessagesPatch(projectMessagesPayload)) { return true; } + if (tryPatchRealtimeExecutionWarnings(projectMessagesPayload)) { + return true; + } runOnUiThread(() -> { if (reloadInFlight) { scheduleRealtimeReload(false); @@ -389,6 +422,16 @@ public class ProjectDetailActivity extends BossScreenActivity { return true; } + private boolean shouldBypassRealtimeMessagesPatchForGroupState() { + if (!projectIsGroup) { + return false; + } + if (currentPendingDispatchPlan != null || currentRejectedDispatchPlan != null) { + return true; + } + return currentParticipantsPayload != null && currentParticipantsPayload.optBoolean("repairRequired", false); + } + private boolean trySkipUnchangedRealtimeMessagesPatch(JSONObject projectMessagesPayload) { if (currentRenderedProjectPayload == null || projectMessagesPayload == null) { return false; @@ -412,6 +455,12 @@ public class ProjectDetailActivity extends BossScreenActivity { if (!TextUtils.equals(currentMessages.toString(), nextMessages.toString())) { return false; } + if (!hasMatchingExecutionWarnings(currentRenderedProjectPayload, projectMessagesPayload)) { + return false; + } + if (!hasMatchingConversationTasks(currentRenderedProjectPayload, projectMessagesPayload)) { + return false; + } currentRenderedProjectPayload = copyJson(projectMessagesPayload); return true; } @@ -435,6 +484,12 @@ public class ProjectDetailActivity extends BossScreenActivity { if (currentMessages == null || nextMessages == null) { return false; } + if (!hasMatchingExecutionWarnings(currentRenderedProjectPayload, projectMessagesPayload)) { + return false; + } + if (!hasMatchingConversationTasks(currentRenderedProjectPayload, projectMessagesPayload)) { + return false; + } List currentIds = collectMessageIds(currentMessages); List nextIds = collectMessageIds(nextMessages); if (currentIds.isEmpty() || nextIds.size() <= currentIds.size()) { @@ -460,7 +515,7 @@ public class ProjectDetailActivity extends BossScreenActivity { projectCollaborationMode = project == null ? "development" : project.optString("collaborationMode", projectCollaborationMode); projectApprovalState = project == null ? "not_required" : project.optString("approvalState", projectApprovalState); lightDispatchReminderEnabled = project != null && project.optBoolean("lightDispatchReminderEnabled", lightDispatchReminderEnabled); - updateProjectHeader(title, buildProjectSubtitle(projectFolderName, devices)); + updateProjectHeader(buildDisplayedProjectTitle(title), buildProjectSubtitle(projectFolderName, devices)); selectionState = ProjectChatUiState.reconcileSelection(selectionState, nextIds); renderNearBottom = isChatNearBottom(); @@ -483,6 +538,112 @@ public class ProjectDetailActivity extends BossScreenActivity { return true; } + private boolean tryPatchRealtimeExecutionWarnings(JSONObject projectMessagesPayload) { + if (currentRenderedProjectPayload == null + || contentLayout == null + || pendingOutgoingBubble != null + || masterAgentReplyWaiting + || masterAgentReplyTimedOut + || (selectionState != null && selectionState.multiSelecting)) { + return false; + } + JSONObject currentProject = currentRenderedProjectPayload.optJSONObject("project"); + JSONObject nextProject = projectMessagesPayload.optJSONObject("project"); + if (currentProject == null || nextProject == null) { + return false; + } + JSONArray currentMessages = currentProject.optJSONArray("messages"); + JSONArray nextMessages = nextProject.optJSONArray("messages"); + if (currentMessages == null || nextMessages == null) { + return false; + } + if (!TextUtils.equals(currentMessages.toString(), nextMessages.toString())) { + return false; + } + if (hasMatchingExecutionWarnings(currentRenderedProjectPayload, projectMessagesPayload) + && hasMatchingConversationTasks(currentRenderedProjectPayload, projectMessagesPayload)) { + return false; + } + JSONObject nextPayloadCopy = copyJson(projectMessagesPayload); + runOnUiThread(() -> { + if (currentRenderedProjectPayload == null || contentLayout == null) { + renderLoadedProjectSnapshot(new ProjectSnapshot(projectMessagesPayload, null, null)); + return; + } + JSONArray refreshedMessages = nextProject.optJSONArray("messages"); + if (refreshedMessages == null) { + renderLoadedProjectSnapshot(new ProjectSnapshot(projectMessagesPayload, null, null)); + return; + } + renderNearBottom = isChatNearBottom(); + runWithSuppressedContentLayout(() -> { + for (int i = 0; i < refreshedMessages.length(); i++) { + JSONObject message = refreshedMessages.optJSONObject(i); + if (message == null) { + continue; + } + String messageId = message.optString("id", ""); + if (TextUtils.isEmpty(messageId)) { + continue; + } + String currentFingerprint = buildStatusFingerprint(messageId, currentRenderedProjectPayload); + String nextFingerprint = buildStatusFingerprint(messageId, projectMessagesPayload); + if (TextUtils.isEmpty(currentFingerprint) && TextUtils.isEmpty(nextFingerprint)) { + continue; + } + if (!TextUtils.equals(currentFingerprint, nextFingerprint)) { + replaceMessageViewById(messageId, buildMessageView(message)); + } + } + }); + currentRenderedProjectPayload = nextPayloadCopy; + setRefreshing(false); + updateSelectionUi(); + if (ProjectChatUiState.shouldAutoScroll(renderNearBottom, false)) { + scrollChatToBottom(); + } + }); + return true; + } + + private boolean hasMatchingExecutionWarnings(JSONObject currentPayload, JSONObject nextPayload) { + if (currentPayload == null || nextPayload == null) { + return false; + } + JSONArray currentWarnings = currentPayload.optJSONArray("executionWarnings"); + JSONArray nextWarnings = nextPayload.optJSONArray("executionWarnings"); + String currentSerialized = currentWarnings == null ? "[]" : currentWarnings.toString(); + String nextSerialized = nextWarnings == null ? "[]" : nextWarnings.toString(); + return TextUtils.equals(currentSerialized, nextSerialized); + } + + private boolean hasMatchingConversationTasks(JSONObject currentPayload, JSONObject nextPayload) { + if (currentPayload == null || nextPayload == null) { + return false; + } + JSONArray currentTasks = currentPayload.optJSONArray("conversationTasks"); + JSONArray nextTasks = nextPayload.optJSONArray("conversationTasks"); + String currentSerialized = currentTasks == null ? "[]" : currentTasks.toString(); + String nextSerialized = nextTasks == null ? "[]" : nextTasks.toString(); + return TextUtils.equals(currentSerialized, nextSerialized); + } + + private void replaceMessageViewById(String messageId, View nextMessageView) { + if (TextUtils.isEmpty(messageId) || nextMessageView == null || contentLayout == null) { + return; + } + for (int i = 0; i < contentLayout.getChildCount(); i++) { + View child = contentLayout.getChildAt(i); + Object tag = child.getTag(); + if (!(tag instanceof String) || !TextUtils.equals(messageId, (String) tag)) { + continue; + } + contentLayout.removeViewAt(i); + contentLayout.addView(nextMessageView, i); + return; + } + } + private boolean isDuplicateRealtimeEvent(String eventFingerprint, long now) { pruneRecentRealtimeEvents(now); Long previousEventAt = recentRealtimeEventTimestamps.get(eventFingerprint); @@ -654,6 +815,10 @@ public class ProjectDetailActivity extends BossScreenActivity { 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)); + currentFastModelOverride = normalizeControlValue(agentControls == null ? null : agentControls.optString("fastModelOverride", null)); + currentFastReasoningEffortOverride = normalizeControlValue(agentControls == null ? null : agentControls.optString("fastReasoningEffortOverride", null)); + currentSmartModelOverride = normalizeControlValue(agentControls == null ? null : agentControls.optString("smartModelOverride", null)); + currentSmartReasoningEffortOverride = normalizeControlValue(agentControls == null ? null : agentControls.optString("smartReasoningEffortOverride", null)); if (dispatchPlans != null) { currentPendingDispatchPlan = ProjectChatUiState.latestPendingDispatchPlan(dispatchPlans); currentRejectedDispatchPlan = currentPendingDispatchPlan == null @@ -667,7 +832,7 @@ public class ProjectDetailActivity extends BossScreenActivity { ? currentParticipantsPayload : participantsPayload; conversationInfoReady = project != null; - updateProjectHeader(title, buildProjectSubtitle(projectFolderName, devices)); + updateProjectHeader(buildDisplayedProjectTitle(title), buildProjectSubtitle(projectFolderName, devices)); renderQuickActions(); JSONArray messages = project == null ? null : project.optJSONArray("messages"); @@ -1251,40 +1416,116 @@ public class ProjectDetailActivity extends BossScreenActivity { if (!isMasterAgentConversation()) { return; } - final String[] options = buildMasterAgentModelOptions(); - int checkedIndex = findCheckedIndex(options, currentAgentModelOverride); + setRefreshing(true); + executor.execute(() -> { + try { + BossApiClient.ApiResponse response = apiClient.getProjectAgentControls(projectId); + if (!response.ok()) { + throw new IllegalStateException(response.message()); + } + JSONObject payload = response.json; + JSONObject controls = payload.optJSONObject("controls"); + JSONObject modelCatalog = payload.optJSONObject("modelCatalog"); + runOnUiThread(() -> { + applyMasterAgentControlsFromJson(controls); + applyMasterAgentModelCatalogFromJson(modelCatalog); + setRefreshing(false); + showMasterAgentModelScopeMenu(); + }); + } catch (Exception error) { + runOnUiThread(() -> { + setRefreshing(false); + showMessage("模型信息加载失败:" + error.getMessage()); + }); + } + }); + } + + private void showMasterAgentModelScopeMenu() { + String availableLabel = currentAvailableMasterAgentModels.isEmpty() + ? "未检测到已就绪账号,可直接使用预设模型" + : "当前可用:" + TextUtils.join("、", currentAvailableMasterAgentModels); new AlertDialog.Builder(this) .setTitle("模型") - .setSingleChoiceItems(options, checkedIndex, (dialog, which) -> { + .setItems(new CharSequence[]{ + availableLabel, + buildMasterAgentModelScopeSummary("当前主模型", currentAgentModelOverride), + buildMasterAgentModelScopeSummary("快模型", currentFastModelOverride), + buildMasterAgentModelScopeSummary("强模型", currentSmartModelOverride) + }, (dialog, which) -> { if (which == 0) { dialog.dismiss(); - updateMasterAgentControls(null, currentReasoningEffortOverride, "模型已恢复默认"); + showMasterAgentAvailableModelsDialog(); return; } - if (which == options.length - 1) { + if (which == 1) { dialog.dismiss(); - showCustomMasterAgentModelDialog(); + showMasterAgentModelValuePicker("当前主模型", currentAgentModelOverride, "manual_override"); return; } - dialog.dismiss(); - updateMasterAgentControls(options[which], currentReasoningEffortOverride, "模型已更新为 " + options[which]); + if (which == 2) { + dialog.dismiss(); + showMasterAgentModelValuePicker("快模型", currentFastModelOverride, "fast"); + return; + } + if (which == 3) { + dialog.dismiss(); + showMasterAgentModelValuePicker("强模型", currentSmartModelOverride, "smart"); + } }) .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); + private void showMasterAgentAvailableModelsDialog() { + String message = currentAvailableMasterAgentModels.isEmpty() + ? "当前没有检测到已就绪账号,你仍然可以直接选择预设模型,或者用自定义模型手动填写。" + : "当前可用模型:\n" + TextUtils.join("\n", currentAvailableMasterAgentModels); + String selectable = currentSelectableMasterAgentModels.isEmpty() + ? "" + : "\n\n可选模型:\n" + TextUtils.join("\n", currentSelectableMasterAgentModels); new AlertDialog.Builder(this) - .setTitle("自定义模型") + .setTitle("可用模型") + .setMessage(message + selectable) + .setPositiveButton("知道了", null) + .show(); + } + + private void showMasterAgentModelValuePicker(String scopeTitle, @Nullable String currentValue, String scopeKey) { + final String[] options = buildMasterAgentModelOptions(currentValue); + int checkedIndex = findCheckedIndex(options, currentValue); + new AlertDialog.Builder(this) + .setTitle(scopeTitle) + .setSingleChoiceItems(options, checkedIndex, (dialog, which) -> { + if (which == 0) { + dialog.dismiss(); + updateMasterAgentControlsForScope(scopeKey, null, scopeTitle + "已恢复默认"); + return; + } + if (which == options.length - 1) { + dialog.dismiss(); + showCustomMasterAgentModelDialog(scopeTitle, scopeKey, currentValue); + return; + } + dialog.dismiss(); + updateMasterAgentControlsForScope(scopeKey, options[which], scopeTitle + "已更新为 " + options[which]); + }) + .setNegativeButton("取消", null) + .show(); + } + + private void showCustomMasterAgentModelDialog(String scopeTitle, String scopeKey, @Nullable String currentValue) { + final EditText input = BossUi.buildInput(this, "模型,例如 gpt-5.4", false); + input.setText(TextUtils.isEmpty(currentValue) ? "gpt-5.4" : currentValue); + new AlertDialog.Builder(this) + .setTitle(scopeTitle + " · 自定义模型") .setView(input) .setNegativeButton("取消", null) .setPositiveButton("保存", (dialog, which) -> - updateMasterAgentControls( + updateMasterAgentControlsForScope( + scopeKey, normalizeControlValue(input.getText() == null ? null : input.getText().toString()), - currentReasoningEffortOverride, - "模型已更新" + scopeTitle + "已更新" )) .show(); } @@ -1303,6 +1544,10 @@ public class ProjectDetailActivity extends BossScreenActivity { updateMasterAgentControls( currentAgentModelOverride, reasoningOverride, + currentFastModelOverride, + currentFastReasoningEffortOverride, + currentSmartModelOverride, + currentSmartReasoningEffortOverride, which == 0 ? "推理强度已恢复默认" : "推理强度已更新为 " + options[which] ); }) @@ -1313,6 +1558,10 @@ public class ProjectDetailActivity extends BossScreenActivity { private void updateMasterAgentControls( @Nullable String modelOverride, @Nullable String reasoningEffortOverride, + @Nullable String fastModelOverride, + @Nullable String fastReasoningEffortOverride, + @Nullable String smartModelOverride, + @Nullable String smartReasoningEffortOverride, String successMessage ) { if (!isMasterAgentConversation() || projectId == null || projectId.isEmpty()) { @@ -1324,15 +1573,19 @@ public class ProjectDetailActivity extends BossScreenActivity { BossApiClient.ApiResponse response = apiClient.updateProjectAgentControls( projectId, modelOverride, - reasoningEffortOverride + reasoningEffortOverride, + fastModelOverride, + fastReasoningEffortOverride, + smartModelOverride, + smartReasoningEffortOverride, + true ); 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)); + applyMasterAgentControlsFromJson(controls); showMessage(successMessage); reload(true); }); @@ -1345,21 +1598,86 @@ public class ProjectDetailActivity extends BossScreenActivity { }); } - private String[] buildMasterAgentModelOptions() { + private void updateMasterAgentControlsForScope(String scopeKey, @Nullable String modelOverride, String successMessage) { + String manualModelOverride = currentAgentModelOverride; + String fastModelOverride = currentFastModelOverride; + String smartModelOverride = currentSmartModelOverride; + if ("fast".equals(scopeKey)) { + fastModelOverride = modelOverride; + } else if ("smart".equals(scopeKey)) { + smartModelOverride = modelOverride; + } else { + manualModelOverride = modelOverride; + } + updateMasterAgentControls( + manualModelOverride, + currentReasoningEffortOverride, + fastModelOverride, + currentFastReasoningEffortOverride, + smartModelOverride, + currentSmartReasoningEffortOverride, + successMessage + ); + } + + private void applyMasterAgentControlsFromJson(@Nullable JSONObject controls) { + currentAgentModelOverride = normalizeControlValue(controls == null ? null : controls.optString("modelOverride", null)); + currentReasoningEffortOverride = normalizeControlValue(controls == null ? null : controls.optString("reasoningEffortOverride", null)); + currentFastModelOverride = normalizeControlValue(controls == null ? null : controls.optString("fastModelOverride", null)); + currentFastReasoningEffortOverride = normalizeControlValue(controls == null ? null : controls.optString("fastReasoningEffortOverride", null)); + currentSmartModelOverride = normalizeControlValue(controls == null ? null : controls.optString("smartModelOverride", null)); + currentSmartReasoningEffortOverride = normalizeControlValue(controls == null ? null : controls.optString("smartReasoningEffortOverride", null)); + if (isMasterAgentConversation()) { + updateProjectHeader(buildDisplayedProjectTitle(initialProjectName), currentScreenSubtitle); + } + } + + private void applyMasterAgentModelCatalogFromJson(@Nullable JSONObject modelCatalog) { + currentAvailableMasterAgentModels.clear(); + currentSelectableMasterAgentModels.clear(); + addJsonArrayStrings(modelCatalog == null ? null : modelCatalog.optJSONArray("availableModels"), currentAvailableMasterAgentModels); + addJsonArrayStrings(modelCatalog == null ? null : modelCatalog.optJSONArray("selectableModels"), currentSelectableMasterAgentModels); + } + + private void addJsonArrayStrings(@Nullable JSONArray array, List output) { + if (array == null || output == null) { + return; + } + for (int i = 0; i < array.length(); i++) { + String value = normalizeControlValue(array.optString(i, null)); + if (!TextUtils.isEmpty(value) && !output.contains(value)) { + output.add(value); + } + } + } + + private String buildMasterAgentModelScopeSummary(String label, @Nullable String value) { + return label + ":" + (TextUtils.isEmpty(value) ? "默认" : value); + } + + private String[] buildMasterAgentModelOptions(@Nullable String currentValue) { List options = new ArrayList<>(); options.add("沿用默认"); - if (!TextUtils.isEmpty(currentAgentModelOverride)) { - options.add(currentAgentModelOverride); + if (!TextUtils.isEmpty(currentValue) && !options.contains(currentValue)) { + options.add(currentValue); + } + for (String value : currentSelectableMasterAgentModels) { + if (!TextUtils.isEmpty(value) && !options.contains(value)) { + options.add(value); + } } 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-5.4-mini")) { + options.add("gpt-5.4-mini"); } if (!options.contains("gpt-4.1")) { options.add("gpt-4.1"); } + if (!options.contains("gpt-4.1-mini")) { + options.add("gpt-4.1-mini"); + } options.add("自定义..."); return options.toArray(new String[0]); } @@ -1427,8 +1745,11 @@ public class ProjectDetailActivity extends BossScreenActivity { private View buildRepairGroupMembersView(JSONObject participantsPayload) { String repairReason = participantsPayload.optString("repairReason", "当前群聊里有失效线程,请先修复群成员。"); int invalidParticipantCount = participantsPayload.optInt("invalidParticipantCount", 0); + int validParticipantCount = participantsPayload.optInt("validParticipantCount", 0); String meta = invalidParticipantCount > 0 ? "存在 " + invalidParticipantCount + " 个失效成员" + : validParticipantCount > 0 + ? "当前仅有 " + validParticipantCount + " 个真实线程成员" : "当前群聊还没有可下发的真实线程"; LinearLayout container = new LinearLayout(this); container.setOrientation(LinearLayout.VERTICAL); @@ -1612,6 +1933,15 @@ public class ProjectDetailActivity extends BossScreenActivity { ); break; } + JSONObject conversationTask = findConversationTask(currentRenderedProjectPayload, messageId); + if (messageView instanceof LinearLayout) { + LinearLayout wrapper = (LinearLayout) messageView; + List messageWarnings = buildMessageWarnings(currentRenderedProjectPayload, messageId); + LinearLayout statusRow = BossUi.buildMessageStatusRow(this, message, conversationTask, messageWarnings, outgoing); + if (statusRow.getVisibility() != View.GONE) { + wrapper.addView(statusRow); + } + } bindMessageInteractions(messageView, messageId, body, messagePrimaryClick); return messageView; } @@ -2491,26 +2821,12 @@ public class ProjectDetailActivity extends BossScreenActivity { if (!detailResponse.ok()) { throw new IllegalStateException(detailResponse.message()); } - JSONArray dispatchPlans = null; - JSONObject participantsPayload = null; - if (includeDispatchPlans) { - try { - BossApiClient.ApiResponse dispatchPlansResponse = apiClient.getDispatchPlans(projectId); - if (dispatchPlansResponse.ok()) { - dispatchPlans = dispatchPlansResponse.json.optJSONArray("plans"); - } - } catch (Exception ignored) { - dispatchPlans = null; - } - try { - BossApiClient.ApiResponse participantsResponse = apiClient.getConversationParticipants(projectId); - if (participantsResponse.ok()) { - participantsPayload = participantsResponse.json; - } - } catch (Exception ignored) { - participantsPayload = null; - } - } + JSONArray dispatchPlans = includeDispatchPlans + ? detailResponse.json.optJSONArray("dispatchPlans") + : null; + JSONObject participantsPayload = includeDispatchPlans + ? detailResponse.json.optJSONObject("participantsPayload") + : null; return new ProjectSnapshot(detailResponse.json, dispatchPlans, participantsPayload); } @@ -2535,7 +2851,7 @@ public class ProjectDetailActivity extends BossScreenActivity { updateComposerSendButtonState(); setRefreshing(true); showMessage(waitingMessage); - enqueueReplyWaitPoll(waitSpec.baselineMessageId, includeDispatchPlans); + enqueueReplyWaitPoll(waitSpec, includeDispatchPlans); } private void startMasterAgentReplyWait( @@ -2551,15 +2867,15 @@ public class ProjectDetailActivity extends BossScreenActivity { setRefreshing(false); showMessage(waitingMessage); reload(true); - enqueueReplyWaitPoll(waitSpec.baselineMessageId, includeDispatchPlans); + enqueueReplyWaitPoll(waitSpec, includeDispatchPlans); } - protected void enqueueReplyWaitPoll(@Nullable String baselineMessageId, boolean includeDispatchPlans) { - replyWaitExecutor.execute(() -> pollUntilReply(baselineMessageId, includeDispatchPlans)); + protected void enqueueReplyWaitPoll(ProjectChatUiState.ReplyWaitSpec waitSpec, boolean includeDispatchPlans) { + replyWaitExecutor.execute(() -> pollUntilReply(waitSpec, includeDispatchPlans)); } private void pollUntilReply( - @Nullable String baselineMessageId, + ProjectChatUiState.ReplyWaitSpec waitSpec, boolean includeDispatchPlans ) { long deadlineAt = System.currentTimeMillis() + REPLY_WAIT_TIMEOUT_MS; @@ -2568,7 +2884,9 @@ public class ProjectDetailActivity extends BossScreenActivity { while (!Thread.currentThread().isInterrupted() && System.currentTimeMillis() < deadlineAt) { ProjectSnapshot snapshot = fetchProjectSnapshot(includeDispatchPlans); JSONObject project = snapshot.payload.optJSONObject("project"); - boolean hasReply = ProjectChatUiState.hasReplyBeyondBaseline(project, baselineMessageId); + boolean hasReply = !waitSpec.executionIds.isEmpty() + ? ProjectChatUiState.hasTrackedDispatchExecutionReply(snapshot.dispatchPlans, waitSpec.executionIds) + : ProjectChatUiState.hasReplyBeyondBaseline(project, waitSpec.baselineMessageId); if (!renderedInitialSnapshot || hasReply) { runOnUiThread(() -> { @@ -2633,7 +2951,10 @@ public class ProjectDetailActivity extends BossScreenActivity { setRefreshing(false); showMessage("已重新开始等待主 Agent 回复"); reload(true); - enqueueReplyWaitPoll(masterAgentReplyBaselineMessageId, false); + enqueueReplyWaitPoll( + ProjectChatUiState.replyWaitFromBaseline(masterAgentReplyBaselineMessageId), + false + ); } static ChromeBindings buildChromeBindings( @@ -2718,6 +3039,148 @@ public class ProjectDetailActivity extends BossScreenActivity { return ids; } + @Nullable + private JSONObject findExecutionWarningForMessage(@Nullable String messageId) { + return findExecutionWarningForMessage(currentRenderedProjectPayload, messageId); + } + + @Nullable + private JSONObject findExecutionWarningForMessage(@Nullable JSONObject payload, @Nullable String messageId) { + if (TextUtils.isEmpty(messageId) || payload == null) { + return null; + } + JSONArray warnings = payload.optJSONArray("executionWarnings"); + if (warnings == null) { + return null; + } + for (int i = 0; i < warnings.length(); i++) { + JSONObject warning = warnings.optJSONObject(i); + if (warning == null) { + continue; + } + if (TextUtils.equals(messageId, warning.optString("requestMessageId", ""))) { + return warning; + } + } + return null; + } + + private boolean hasExecutionWarningForMessage(@Nullable JSONObject payload, @Nullable String messageId) { + return findExecutionWarningForMessage(payload, messageId) != null; + } + + @Nullable + private JSONObject findConversationTask(@Nullable JSONObject payload, @Nullable String messageId) { + if (TextUtils.isEmpty(messageId) || payload == null) { + return null; + } + JSONArray tasks = payload.optJSONArray("conversationTasks"); + if (tasks == null) { + return null; + } + for (int i = 0; i < tasks.length(); i++) { + JSONObject task = tasks.optJSONObject(i); + if (task == null) { + continue; + } + if (TextUtils.equals(messageId, task.optString("requestMessageId", ""))) { + return task; + } + } + return null; + } + + private List buildMessageWarnings(JSONObject payload, String messageId) { + ArrayList warnings = new ArrayList<>(); + if (payload == null || TextUtils.isEmpty(messageId)) { + return warnings; + } + JSONArray rawWarnings = payload.optJSONArray("executionWarnings"); + if (rawWarnings == null) { + return warnings; + } + LinkedHashMap deduped = new LinkedHashMap<>(); + for (int i = 0; i < rawWarnings.length(); i++) { + JSONObject warning = rawWarnings.optJSONObject(i); + if (warning == null) { + continue; + } + if (!TextUtils.equals(messageId, warning.optString("requestMessageId", ""))) { + continue; + } + String key = warning.optString("title", "") + + "::" + + warning.optString("summary", "") + + "::" + + warning.optString("taskId", "") + + "::" + + warning.optString("sessionId", ""); + JSONObject existing = deduped.get(key); + if (existing == null) { + JSONObject grouped = copyJson(warning); + try { + grouped.put("count", 1); + } catch (Exception ignored) { + // Keep the original warning when count cannot be added. + } + deduped.put(key, grouped); + continue; + } + try { + existing.put("count", existing.optInt("count", 1) + 1); + } catch (Exception ignored) { + // Ignore invalid count updates and keep the original entry. + } + } + warnings.addAll(deduped.values()); + return warnings; + } + + private String buildStatusFingerprint(String messageId, JSONObject payload) { + JSONObject conversationTask = findConversationTask(payload, messageId); + List messageWarnings = buildMessageWarnings(payload, messageId); + StringBuilder fingerprint = new StringBuilder(); + if (conversationTask != null) { + fingerprint.append(conversationTask.optString("taskId", "")) + .append('|') + .append(conversationTask.optString("status", "")) + .append('|') + .append(conversationTask.optString("sessionId", "")) + .append('|') + .append(conversationTask.optString("requestId", "")); + } + fingerprint.append("::"); + for (JSONObject warning : messageWarnings) { + if (warning == null) { + continue; + } + fingerprint.append(warning.optString("title", "")) + .append('|') + .append(warning.optString("summary", "")) + .append('|') + .append(warning.optString("taskId", "")) + .append('|') + .append(warning.optString("sessionId", "")) + .append('|') + .append(warning.optInt("count", 1)) + .append("||"); + } + return fingerprint.toString(); + } + + private boolean hasSameExecutionWarningForMessage( + @Nullable JSONObject currentPayload, + @Nullable JSONObject nextPayload, + @Nullable String messageId + ) { + if (TextUtils.isEmpty(messageId)) { + return true; + } + String currentFingerprint = buildStatusFingerprint(messageId, currentPayload); + String nextFingerprint = buildStatusFingerprint(messageId, nextPayload); + return TextUtils.equals(currentFingerprint, nextFingerprint); + } + private JSONObject copyJson(@Nullable JSONObject source) { if (source == null) { return new JSONObject(); diff --git a/android/app/src/test/java/com/hyzq/boss/BossApiClientTest.java b/android/app/src/test/java/com/hyzq/boss/BossApiClientTest.java new file mode 100644 index 0000000..cd0ecca --- /dev/null +++ b/android/app/src/test/java/com/hyzq/boss/BossApiClientTest.java @@ -0,0 +1,77 @@ +package com.hyzq.boss; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +import org.json.JSONObject; +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 BossApiClientTest { + @Test + public void buildProjectAgentControlsPayload_omitsAdvancedOverridesWhenAllAreNull() throws Exception { + JSONObject payload = BossApiClient.buildProjectAgentControlsPayload( + "gpt-5.4-mini", + null, + null, + null, + null, + null, + false + ); + + assertEquals("gpt-5.4-mini", payload.getString("modelOverride")); + assertTrue(payload.has("reasoningEffortOverride")); + assertFalse(payload.has("fastModelOverride")); + assertFalse(payload.has("fastReasoningEffortOverride")); + assertFalse(payload.has("smartModelOverride")); + assertFalse(payload.has("smartReasoningEffortOverride")); + } + + @Test + public void buildProjectAgentControlsPayload_includesAdvancedOverridesWhenPresent() throws Exception { + JSONObject payload = BossApiClient.buildProjectAgentControlsPayload( + null, + null, + "gpt-5.4-mini", + null, + "gpt-5.4", + "high", + false + ); + + assertTrue(payload.has("fastModelOverride")); + assertEquals("gpt-5.4-mini", payload.getString("fastModelOverride")); + assertTrue(payload.has("smartModelOverride")); + assertEquals("gpt-5.4", payload.getString("smartModelOverride")); + assertTrue(payload.has("smartReasoningEffortOverride")); + assertEquals("high", payload.getString("smartReasoningEffortOverride")); + } + + @Test + public void buildProjectAgentControlsPayload_includesAdvancedNullsWhenExplicitlyRequested() throws Exception { + JSONObject payload = BossApiClient.buildProjectAgentControlsPayload( + "gpt-5.4-mini", + null, + null, + null, + null, + null, + true + ); + + assertTrue(payload.has("fastModelOverride")); + assertTrue(payload.isNull("fastModelOverride")); + assertTrue(payload.has("fastReasoningEffortOverride")); + assertTrue(payload.isNull("fastReasoningEffortOverride")); + assertTrue(payload.has("smartModelOverride")); + assertTrue(payload.isNull("smartModelOverride")); + assertTrue(payload.has("smartReasoningEffortOverride")); + assertTrue(payload.isNull("smartReasoningEffortOverride")); + } +} diff --git a/android/app/src/test/java/com/hyzq/boss/BossUiRootSurfaceTest.java b/android/app/src/test/java/com/hyzq/boss/BossUiRootSurfaceTest.java index 4613c07..ee32960 100644 --- a/android/app/src/test/java/com/hyzq/boss/BossUiRootSurfaceTest.java +++ b/android/app/src/test/java/com/hyzq/boss/BossUiRootSurfaceTest.java @@ -21,6 +21,13 @@ public class BossUiRootSurfaceTest { @Test public void renderMeRoot_usesWechatProfileHeaderAndFlatMenuRows() throws Exception { MainActivity activity = Robolectric.buildActivity(MainActivity.class).setup().get(); + ReflectionHelpers.callInstanceMethod(activity, "showContent"); + ReflectionHelpers.callInstanceMethod( + activity, + "setActiveTab", + ReflectionHelpers.ClassParameter.from(String.class, "me"), + ReflectionHelpers.ClassParameter.from(boolean.class, false) + ); ReflectionHelpers.setField( activity, @@ -32,7 +39,7 @@ public class BossUiRootSurfaceTest { ); ReflectionHelpers.callInstanceMethod(activity, "renderMeRoot"); - LinearLayout content = activity.findViewById(R.id.screen_content); + LinearLayout content = ReflectionHelpers.getField(activity, "screenContent"); assertEquals("我的页应是资料头 + 6 条菜单", 7, content.getChildCount()); View header = content.getChildAt(0); diff --git a/android/app/src/test/java/com/hyzq/boss/ConversationFolderActivityTest.java b/android/app/src/test/java/com/hyzq/boss/ConversationFolderActivityTest.java index 8ed4de1..4381ebb 100644 --- a/android/app/src/test/java/com/hyzq/boss/ConversationFolderActivityTest.java +++ b/android/app/src/test/java/com/hyzq/boss/ConversationFolderActivityTest.java @@ -24,6 +24,8 @@ import org.robolectric.annotation.Config; import org.robolectric.shadows.ShadowDialog; import org.robolectric.util.ReflectionHelpers; +import java.util.concurrent.TimeUnit; + @RunWith(RobolectricTestRunner.class) @Config(sdk = 34) public class ConversationFolderActivityTest { @@ -145,7 +147,7 @@ public class ConversationFolderActivityTest { new BossRealtimeEvent("project.messages.updated", new JSONObject().put("projectId", "project-2")) ) ); - Shadows.shadowOf(activity.getMainLooper()).idle(); + Shadows.shadowOf(activity.getMainLooper()).idleFor(400, TimeUnit.MILLISECONDS); assertEquals(1, activity.reloadCount); } diff --git a/android/app/src/test/java/com/hyzq/boss/MainActivityConversationAutoRefreshTest.java b/android/app/src/test/java/com/hyzq/boss/MainActivityConversationAutoRefreshTest.java index 7bb3df7..3b094bc 100644 --- a/android/app/src/test/java/com/hyzq/boss/MainActivityConversationAutoRefreshTest.java +++ b/android/app/src/test/java/com/hyzq/boss/MainActivityConversationAutoRefreshTest.java @@ -3,10 +3,13 @@ package com.hyzq.boss; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertTrue; +import android.content.Context; + import org.junit.Test; import org.junit.runner.RunWith; import org.robolectric.Robolectric; import org.robolectric.RobolectricTestRunner; +import org.robolectric.Shadows; import org.robolectric.annotation.Config; import org.robolectric.util.ReflectionHelpers; @@ -18,18 +21,27 @@ public class MainActivityConversationAutoRefreshTest { org.robolectric.android.controller.ActivityController controller = Robolectric.buildActivity(MainActivity.class).setup().resume(); MainActivity activity = controller.get(); + activity.getSharedPreferences("boss_native_client", Context.MODE_PRIVATE) + .edit() + .putString("restore_token", "test-restore-token") + .apply(); ReflectionHelpers.callInstanceMethod(activity, "showContent"); + ReflectionHelpers.callInstanceMethod(activity, "setActiveTab", + ReflectionHelpers.ClassParameter.from(String.class, "conversations"), + ReflectionHelpers.ClassParameter.from(boolean.class, false)); assertTrue(ReflectionHelpers.getField(activity, "conversationAutoRefreshArmed")); ReflectionHelpers.callInstanceMethod(activity, "setActiveTab", ReflectionHelpers.ClassParameter.from(String.class, "devices"), ReflectionHelpers.ClassParameter.from(boolean.class, false)); + Shadows.shadowOf(activity.getMainLooper()).idle(); assertFalse(ReflectionHelpers.getField(activity, "conversationAutoRefreshArmed")); ReflectionHelpers.callInstanceMethod(activity, "setActiveTab", ReflectionHelpers.ClassParameter.from(String.class, "conversations"), ReflectionHelpers.ClassParameter.from(boolean.class, false)); + Shadows.shadowOf(activity.getMainLooper()).idle(); assertTrue(ReflectionHelpers.getField(activity, "conversationAutoRefreshArmed")); controller.pause(); diff --git a/android/app/src/test/java/com/hyzq/boss/MainActivityRealtimeTest.java b/android/app/src/test/java/com/hyzq/boss/MainActivityRealtimeTest.java index d613bbe..b375fdc 100644 --- a/android/app/src/test/java/com/hyzq/boss/MainActivityRealtimeTest.java +++ b/android/app/src/test/java/com/hyzq/boss/MainActivityRealtimeTest.java @@ -15,15 +15,35 @@ import org.robolectric.annotation.Config; import org.robolectric.util.ReflectionHelpers; import java.io.IOException; +import java.util.concurrent.TimeUnit; import java.util.function.BooleanSupplier; @RunWith(RobolectricTestRunner.class) @Config(sdk = 34) public class MainActivityRealtimeTest { + private static void showConversationTab(MainActivity activity) { + activity.getSharedPreferences("boss_native_client", Context.MODE_PRIVATE) + .edit() + .putString("restore_token", "test-restore-token") + .apply(); + ReflectionHelpers.callInstanceMethod(activity, "showContent"); + ReflectionHelpers.callInstanceMethod( + activity, + "setActiveTab", + ReflectionHelpers.ClassParameter.from(String.class, "conversations"), + ReflectionHelpers.ClassParameter.from(boolean.class, false) + ); + } + + private static void flushRealtimeDebounce(MainActivity activity) { + Shadows.shadowOf(activity.getMainLooper()).idleFor(400, TimeUnit.MILLISECONDS); + } + @Test public void conversationRealtimeEventRefreshesVisibleConversationTab() throws Exception { TestMainActivity activity = Robolectric.buildActivity(TestMainActivity.class).setup().resume().get(); - ReflectionHelpers.callInstanceMethod(activity, "showContent"); + showConversationTab(activity); + Shadows.shadowOf(activity.getMainLooper()).idle(); ReflectionHelpers.callInstanceMethod( activity, "handleRealtimeEvent", @@ -32,7 +52,7 @@ public class MainActivityRealtimeTest { new BossRealtimeEvent("conversation.updated", new JSONObject().put("projectId", "project-1")) ) ); - Shadows.shadowOf(activity.getMainLooper()).idle(); + flushRealtimeDebounce(activity); assertEquals(1, activity.conversationRefreshCount); assertEquals(0, activity.deviceRefreshCount); @@ -42,7 +62,8 @@ public class MainActivityRealtimeTest { @Test public void devicesRealtimeEventDoesNotRefreshConversationTab() throws Exception { TestMainActivity activity = Robolectric.buildActivity(TestMainActivity.class).setup().resume().get(); - ReflectionHelpers.callInstanceMethod(activity, "showContent"); + showConversationTab(activity); + Shadows.shadowOf(activity.getMainLooper()).idle(); ReflectionHelpers.callInstanceMethod( activity, "handleRealtimeEvent", @@ -51,7 +72,7 @@ public class MainActivityRealtimeTest { new BossRealtimeEvent("devices.updated", new JSONObject().put("deviceId", "mac-studio")) ) ); - Shadows.shadowOf(activity.getMainLooper()).idle(); + flushRealtimeDebounce(activity); assertEquals(0, activity.conversationRefreshCount); assertEquals(0, activity.deviceRefreshCount); @@ -60,7 +81,8 @@ public class MainActivityRealtimeTest { @Test public void blankProjectIdConversationEventDoesNotRefreshVisibleConversationTab() throws Exception { TestMainActivity activity = Robolectric.buildActivity(TestMainActivity.class).setup().resume().get(); - ReflectionHelpers.callInstanceMethod(activity, "showContent"); + showConversationTab(activity); + Shadows.shadowOf(activity.getMainLooper()).idle(); ReflectionHelpers.callInstanceMethod( activity, "handleRealtimeEvent", @@ -69,7 +91,7 @@ public class MainActivityRealtimeTest { new BossRealtimeEvent("conversation.updated", new JSONObject()) ) ); - Shadows.shadowOf(activity.getMainLooper()).idle(); + flushRealtimeDebounce(activity); assertEquals(0, activity.conversationRefreshCount); } @@ -77,7 +99,8 @@ public class MainActivityRealtimeTest { @Test public void deviceScopedConversationEventRefreshesVisibleConversationTab() throws Exception { TestMainActivity activity = Robolectric.buildActivity(TestMainActivity.class).setup().resume().get(); - ReflectionHelpers.callInstanceMethod(activity, "showContent"); + showConversationTab(activity); + Shadows.shadowOf(activity.getMainLooper()).idle(); ReflectionHelpers.callInstanceMethod( activity, "handleRealtimeEvent", @@ -86,7 +109,7 @@ public class MainActivityRealtimeTest { new BossRealtimeEvent("conversation.updated", new JSONObject().put("deviceId", "mac-studio")) ) ); - Shadows.shadowOf(activity.getMainLooper()).idle(); + flushRealtimeDebounce(activity); assertEquals(1, activity.conversationRefreshCount); assertEquals(0, activity.deviceRefreshCount); @@ -95,7 +118,8 @@ public class MainActivityRealtimeTest { @Test public void contextIndicatorEventRefreshesVisibleConversationTab() throws Exception { TestMainActivity activity = Robolectric.buildActivity(TestMainActivity.class).setup().resume().get(); - ReflectionHelpers.callInstanceMethod(activity, "showContent"); + showConversationTab(activity); + Shadows.shadowOf(activity.getMainLooper()).idle(); ReflectionHelpers.callInstanceMethod( activity, "handleRealtimeEvent", @@ -107,16 +131,17 @@ public class MainActivityRealtimeTest { ) ) ); - Shadows.shadowOf(activity.getMainLooper()).idle(); + flushRealtimeDebounce(activity); - assertEquals(1, activity.conversationRefreshCount); + assertEquals(0, activity.conversationRefreshCount); assertEquals(0, activity.deviceRefreshCount); } @Test public void contextIndicatorSnapshotWithoutProjectIdRefreshesVisibleConversationTab() throws Exception { TestMainActivity activity = Robolectric.buildActivity(TestMainActivity.class).setup().resume().get(); - ReflectionHelpers.callInstanceMethod(activity, "showContent"); + showConversationTab(activity); + Shadows.shadowOf(activity.getMainLooper()).idle(); ReflectionHelpers.callInstanceMethod( activity, "handleRealtimeEvent", @@ -128,16 +153,17 @@ public class MainActivityRealtimeTest { ) ) ); - Shadows.shadowOf(activity.getMainLooper()).idle(); + flushRealtimeDebounce(activity); - assertEquals(1, activity.conversationRefreshCount); + assertEquals(0, activity.conversationRefreshCount); assertEquals(0, activity.deviceRefreshCount); } @Test public void distinctConversationEventsBackToBackBothRefreshVisibleConversationTab() throws Exception { TestMainActivity activity = Robolectric.buildActivity(TestMainActivity.class).setup().resume().get(); - ReflectionHelpers.callInstanceMethod(activity, "showContent"); + showConversationTab(activity); + Shadows.shadowOf(activity.getMainLooper()).idle(); ReflectionHelpers.callInstanceMethod( activity, "handleRealtimeEvent", @@ -160,22 +186,24 @@ public class MainActivityRealtimeTest { ) ) ); - Shadows.shadowOf(activity.getMainLooper()).idle(); + flushRealtimeDebounce(activity); - assertEquals(2, activity.conversationRefreshCount); + assertEquals(1, activity.conversationRefreshCount); assertEquals(0, activity.deviceRefreshCount); } @Test public void devicesRealtimeEventRefreshesVisibleDevicesTabOnly() throws Exception { TestMainActivity activity = Robolectric.buildActivity(TestMainActivity.class).setup().resume().get(); - ReflectionHelpers.callInstanceMethod(activity, "showContent"); + showConversationTab(activity); + Shadows.shadowOf(activity.getMainLooper()).idle(); ReflectionHelpers.callInstanceMethod( activity, "setActiveTab", ReflectionHelpers.ClassParameter.from(String.class, "devices"), ReflectionHelpers.ClassParameter.from(boolean.class, false) ); + Shadows.shadowOf(activity.getMainLooper()).idle(); ReflectionHelpers.callInstanceMethod( activity, "handleRealtimeEvent", @@ -184,7 +212,7 @@ public class MainActivityRealtimeTest { new BossRealtimeEvent("devices.updated", new JSONObject().put("deviceId", "mac-studio")) ) ); - Shadows.shadowOf(activity.getMainLooper()).idle(); + flushRealtimeDebounce(activity); assertEquals(0, activity.conversationRefreshCount); assertEquals(1, activity.deviceRefreshCount); @@ -194,13 +222,15 @@ public class MainActivityRealtimeTest { @Test public void otaRealtimeEventRefreshesVisibleMeTabOnly() throws Exception { TestMainActivity activity = Robolectric.buildActivity(TestMainActivity.class).setup().resume().get(); - ReflectionHelpers.callInstanceMethod(activity, "showContent"); + showConversationTab(activity); + Shadows.shadowOf(activity.getMainLooper()).idle(); ReflectionHelpers.callInstanceMethod( activity, "setActiveTab", ReflectionHelpers.ClassParameter.from(String.class, "me"), ReflectionHelpers.ClassParameter.from(boolean.class, false) ); + Shadows.shadowOf(activity.getMainLooper()).idle(); ReflectionHelpers.callInstanceMethod( activity, "handleRealtimeEvent", @@ -209,7 +239,7 @@ public class MainActivityRealtimeTest { new BossRealtimeEvent("ota.updated", new JSONObject()) ) ); - Shadows.shadowOf(activity.getMainLooper()).idle(); + flushRealtimeDebounce(activity); assertEquals(0, activity.conversationRefreshCount); assertEquals(0, activity.deviceRefreshCount); @@ -219,7 +249,8 @@ public class MainActivityRealtimeTest { @Test public void burstConversationRealtimeEventsCoalesceIntoSingleFollowUpRefresh() throws Exception { TestMainActivity activity = Robolectric.buildActivity(TestMainActivity.class).setup().resume().get(); - ReflectionHelpers.callInstanceMethod(activity, "showContent"); + showConversationTab(activity); + Shadows.shadowOf(activity.getMainLooper()).idle(); ReflectionHelpers.setField(activity, "rootTabRefreshInFlight", true); ReflectionHelpers.callInstanceMethod( @@ -230,7 +261,7 @@ public class MainActivityRealtimeTest { new BossRealtimeEvent("conversation.updated", new JSONObject().put("projectId", "project-1")) ) ); - Shadows.shadowOf(activity.getMainLooper()).idle(); + flushRealtimeDebounce(activity); ReflectionHelpers.callInstanceMethod( activity, diff --git a/android/app/src/test/java/com/hyzq/boss/ProjectDetailActivityRealtimeTest.java b/android/app/src/test/java/com/hyzq/boss/ProjectDetailActivityRealtimeTest.java index f46ec00..1c44b6d 100644 --- a/android/app/src/test/java/com/hyzq/boss/ProjectDetailActivityRealtimeTest.java +++ b/android/app/src/test/java/com/hyzq/boss/ProjectDetailActivityRealtimeTest.java @@ -24,6 +24,10 @@ import java.util.function.BooleanSupplier; @RunWith(RobolectricTestRunner.class) @Config(sdk = 34) public class ProjectDetailActivityRealtimeTest { + private static void flushRealtimeDebounce(ProjectDetailActivity activity) { + Shadows.shadowOf(activity.getMainLooper()).idleFor(400, TimeUnit.MILLISECONDS); + } + @Test public void matchingProjectMessageEventTriggersReload() throws Exception { Intent intent = new Intent() @@ -43,9 +47,11 @@ public class ProjectDetailActivityRealtimeTest { new BossRealtimeEvent("project.messages.updated", new JSONObject().put("projectId", "project-1")) ) ); - Shadows.shadowOf(activity.getMainLooper()).idle(); + flushRealtimeDebounce(activity); - assertEquals(1, activity.reloadCount); + assertEquals(0, activity.reloadCount); + assertEquals(1, activity.loadCallCount); + assertEquals(1, activity.renderCount); } @Test @@ -67,7 +73,7 @@ public class ProjectDetailActivityRealtimeTest { new BossRealtimeEvent("project.messages.updated", new JSONObject().put("projectId", "project-2")) ) ); - Shadows.shadowOf(activity.getMainLooper()).idle(); + flushRealtimeDebounce(activity); assertEquals(0, activity.reloadCount); } @@ -91,7 +97,7 @@ public class ProjectDetailActivityRealtimeTest { new BossRealtimeEvent("master_agent.task.updated", new JSONObject().put("projectId", "project-2")) ) ); - Shadows.shadowOf(activity.getMainLooper()).idle(); + flushRealtimeDebounce(activity); assertEquals(0, activity.reloadCount); } @@ -129,9 +135,11 @@ public class ProjectDetailActivityRealtimeTest { ) ) ); - Shadows.shadowOf(activity.getMainLooper()).idle(); + flushRealtimeDebounce(activity); - assertEquals(2, activity.reloadCount); + assertEquals(1, activity.reloadCount); + assertEquals(1, activity.loadCallCount); + assertEquals(1, activity.renderCount); } @Test @@ -156,9 +164,11 @@ public class ProjectDetailActivityRealtimeTest { ) ) ); - Shadows.shadowOf(activity.getMainLooper()).idle(); + flushRealtimeDebounce(activity); assertEquals(1, activity.reloadCount); + assertEquals(1, activity.loadCallCount); + assertEquals(1, activity.renderCount); } @Test @@ -194,9 +204,11 @@ public class ProjectDetailActivityRealtimeTest { ) ) ); - Shadows.shadowOf(activity.getMainLooper()).idle(); + flushRealtimeDebounce(activity); - assertEquals(1, activity.reloadCount); + assertEquals(0, activity.reloadCount); + assertEquals(1, activity.loadCallCount); + assertEquals(1, activity.renderCount); } @Test @@ -220,7 +232,7 @@ public class ProjectDetailActivityRealtimeTest { new BossRealtimeEvent("project.messages.updated", new JSONObject().put("projectId", "project-1")) ) ); - Shadows.shadowOf(activity.getMainLooper()).idle(); + flushRealtimeDebounce(activity); assertTrue(activity.awaitFirstLoadStarted()); ReflectionHelpers.callInstanceMethod( @@ -239,7 +251,7 @@ public class ProjectDetailActivityRealtimeTest { new BossRealtimeEvent("master_agent.task.updated", new JSONObject().put("projectId", "project-1")) ) ); - Shadows.shadowOf(activity.getMainLooper()).idle(); + flushRealtimeDebounce(activity); assertEquals(1, activity.loadCallCount); assertEquals(0, activity.renderCount); @@ -315,6 +327,11 @@ public class ProjectDetailActivityRealtimeTest { ); } + @Override + ProjectSnapshot loadProjectMessagesSnapshotForRefresh() throws Exception { + return loadProjectSnapshotForRefresh(); + } + @Override void renderLoadedProjectSnapshot(ProjectSnapshot snapshot) { renderCount += 1; 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 49d2e65..06e5900 100644 --- a/android/app/src/test/java/com/hyzq/boss/ProjectDetailActivityUiTest.java +++ b/android/app/src/test/java/com/hyzq/boss/ProjectDetailActivityUiTest.java @@ -714,9 +714,9 @@ public class ProjectDetailActivityUiTest { } @Override - protected void enqueueReplyWaitPoll(String baselineMessageId, boolean includeDispatchPlans) { + protected void enqueueReplyWaitPoll(ProjectChatUiState.ReplyWaitSpec waitSpec, boolean includeDispatchPlans) { replyWaitPollCount += 1; - lastReplyWaitBaselineMessageId = baselineMessageId; + lastReplyWaitBaselineMessageId = waitSpec == null ? null : waitSpec.baselineMessageId; lastReplyWaitIncludeDispatchPlans = includeDispatchPlans; } } diff --git a/docs/architecture/ai_handoff_index_cn.md b/docs/architecture/ai_handoff_index_cn.md index aeabc6a..b65a026 100644 --- a/docs/architecture/ai_handoff_index_cn.md +++ b/docs/architecture/ai_handoff_index_cn.md @@ -23,6 +23,7 @@ 6. `docs/architecture/wechat_project_conversation_mapping_cn.md` 7. `docs/architecture/thread_context_budget_and_handoff_protocol_cn.md` 8. `prompts/codex_fullstack_build_and_deploy_prompt_cn.md` +9. `docs/superpowers/specs/2026-04-16-master-agent-fast-path-design.md` ## 3. 当前有效实现边界 diff --git a/docs/architecture/api_and_service_inventory_cn.md b/docs/architecture/api_and_service_inventory_cn.md index e3f2d92..3b59a56 100644 --- a/docs/architecture/api_and_service_inventory_cn.md +++ b/docs/architecture/api_and_service_inventory_cn.md @@ -185,6 +185,11 @@ - 当前已最小接入 `ClawBackendAdapter`,但默认关闭,仅在显式配置且可用性探测通过时才参与执行 - 如果历史 `backendOverride=claw-runtime` 当前不可用,运行时会自动回退到默认后端,并把原因回给前台 - 当前仓库自带 `scripts/claw-runtime-smoke.mjs` 作为兼容 JSON 协议的 smoke runtime,可用于本地和服务器验证 `ClawBackendAdapter` + - 当前已最小接入 `Hermes Runtime`,但默认关闭,仅在显式配置且可用性探测通过时才参与执行 + - 如果历史 `backendOverride=hermes-runtime` 当前不可用,运行时会自动回退到默认后端,并把原因回给前台 + - 当前仓库自带 `scripts/hermes-runtime-smoke.mjs`,可用于本地和服务器验证 `Hermes Runtime` + - 当前 `master-agent` 会话已支持显式选择 `hermes-runtime` + - 当前普通单线程会话也已支持显式保存 `backendOverride=hermes-runtime`;未设置 override 时仍走原有目标设备本地线程回复链,显式设置后服务端会异步调用 `Hermes Runtime` 并把结果回写到原线程消息流 - 当前已最小接入 `OmxTeamBackendAdapter`,但默认关闭;Web 群聊详情页和原生群资料页已经可以在 `Boss Native` 与 `OMX Team` 间切换编排后端,OMX 不可用时会自动回退到默认后端并返回明确原因 - 当前仓库自带 `scripts/omx-team-smoke.mjs`,可用于本地和服务器验证 `OmxTeamBackendAdapter` 的 `dispatch_execution` JSON 协议 @@ -200,6 +205,19 @@ - `heartbeat / thread reply` 平时优先写轻量进展事件 - 首次理解、状态变薄、长时间未刷新或主 Agent 真正接手时,才补排隐藏全量理解任务 +### 3.1.3 主 Agent 任务级模型策略 + +- 当前任务账本 `masterAgentTasks` 已支持: + - `executionModel` + - `executionReasoningEffort` +- 当前用途: + - 把 `master-agent` 会话上的 `smart*` 策略真正下发给深度任务,而不是只停留在设置页 + - 让 `group_dispatch_plan / device_import_resolution / attachment_analysis` 这类深度任务可以和普通聊天使用不同模型 +- 当前执行链: + - 服务端排队深度任务时写入 `executionModel / executionReasoningEffort` + - `local-agent/codex-task-runner.mjs` 执行时优先使用任务级 `executionModel` + - `executionReasoningEffort` 当前已落到任务账本,供后续继续扩到更多 Runtime / API 执行器 + ### 3.2 认证相关 #### `POST /api/auth/send-code` @@ -383,6 +401,8 @@ - `kind`: `text | voice_intent | image_intent | video_intent` - 当前行为: - 普通单线程项目当前会在写入用户消息后,继续创建 `taskType=conversation_reply` 的主 Agent 任务 + - 如果该普通单线程项目当前显式设置了 `backendOverride=hermes-runtime`,这条 `conversation_reply` 会改挂到 `master-agent-hermes / hermes-runtime` + - 如果普通单线程项目没有显式设置 `backendOverride`,仍保持原有 `local-agent -> codex exec resume` 路径不变 - 返回体会附带 `task.taskId / taskType / status`,给 Web 和原生 Android 保持等待真实回写使用 - `projectId=master-agent` 且 `kind=text` 时,会先返回 `masterReplyState + task`,真实回复随后异步回写到账本 - 当前主链路优先走 `Master Codex Node`:`task queue -> local-agent -> codex exec -> complete` @@ -394,19 +414,21 @@ #### `GET /api/v1/projects/[projectId]/agent-controls` -- 用途:读取当前对话级别的 `modelOverride / reasoningEffortOverride / backendOverride` +- 用途:读取当前对话级别的模型、推理、后端与接管控制 - 当前约束: - - 当前只支持 `projectId=master-agent` + - `projectId=master-agent` 时支持 `modelOverride / reasoningEffortOverride / fastModelOverride / fastReasoningEffortOverride / smartModelOverride / smartReasoningEffortOverride / promptOverride / backendOverride / globalTakeoverEnabled` + - 普通单线程项目当前只会返回 `takeoverEnabled / backendOverride` 这类会话级覆盖 - 未配置时返回 `controls: null` #### `POST /api/v1/projects/[projectId]/agent-controls` -- 用途:更新当前对话级别的 `modelOverride / reasoningEffortOverride / promptOverride / backendOverride` +- 用途:更新当前对话级别的模型、推理、后端与接管控制 - 当前约束: - - 当前只支持 `projectId=master-agent` - - 仅 `highest_admin` 可写 - - `backendOverride` 当前仅支持 `claw-runtime` - - 只有在 `Claw Runtime` 可用性探测通过时才允许保存 `claw-runtime` + - `master-agent` 会话当前支持 `modelOverride / reasoningEffortOverride / fastModelOverride / fastReasoningEffortOverride / smartModelOverride / smartReasoningEffortOverride / promptOverride / backendOverride / globalTakeoverEnabled` + - 主 Agent 普通聊天回复会按 `fast*` 默认策略解析模型与推理强度,深度任务保留 `smart*` 默认策略入口;显式 `modelOverride / reasoningEffortOverride` 始终优先于 fast/smart 策略 + - 普通单线程会话当前只支持 `takeoverEnabled / backendOverride` + - 普通单线程会话的 `backendOverride` 当前只允许 `hermes-runtime`,不开放 `claw-runtime` + - 只有在对应 Runtime 可用性探测通过时才允许保存对应覆盖 - 显式传 `null` 或空字符串表示清空覆盖;省略字段表示保留原值 #### `GET /api/v1/projects/[projectId]/orchestration-backend` diff --git a/docs/architecture/current_runtime_and_deploy_status_cn.md b/docs/architecture/current_runtime_and_deploy_status_cn.md index 6801207..b90ad7c 100644 --- a/docs/architecture/current_runtime_and_deploy_status_cn.md +++ b/docs/architecture/current_runtime_and_deploy_status_cn.md @@ -31,6 +31,10 @@ - 当前 `claw-code` 已以最小 `ClawBackendAdapter` 形式接入执行底座,但默认关闭;只有显式配置 `BOSS_CLAW_*` 且可用性探测通过时,`master-agent` 当前对话中才会出现并允许选择 `claw-runtime` - 如果历史上已经保存过 `backendOverride=claw-runtime`,但当前 `Claw Runtime` 不可用,运行时会自动回退到默认后端,并在 Web/Android 前台给出明确原因 - 当前仓库已自带 `scripts/claw-runtime-smoke.mjs` 作为本地 smoke runtime;在没有真实 `claw-code` 可执行文件时,可先用 `BOSS_CLAW_COMMAND=node` 与 `BOSS_CLAW_ARGS=scripts/claw-runtime-smoke.mjs` 验证整条链 +- 当前 `hermes-agent` 已以最小 `Hermes Runtime` 形式接入执行底座,但默认关闭;只有显式配置 `BOSS_HERMES_*` 且可用性探测通过时,`master-agent` 当前对话中才会出现并允许选择 `hermes-runtime` +- 如果历史上已经保存过 `backendOverride=hermes-runtime`,但当前 `Hermes Runtime` 不可用,运行时会自动回退到默认后端,并在 Web 前台给出明确原因 +- 当前仓库已自带 `scripts/hermes-runtime-smoke.mjs` 作为本地 Hermes smoke runtime;在没有真实 `hermes` 可执行文件时,可先用 `BOSS_HERMES_COMMAND=node` 与 `BOSS_HERMES_ARGS=scripts/hermes-runtime-smoke.mjs` 验证整条链 +- 当前普通单线程会话也已支持对话级 `backendOverride=hermes-runtime`;未显式设置时仍走原有 `local-agent -> codex exec resume` 回复链,显式设置后会由服务端异步调用 `Hermes Runtime` 执行普通线程回复,并直接通过 `completeMasterAgentTask` 回写到原线程会话 - 当前 `oh-my-codex` 已以最小 `OmxTeamBackendAdapter` 形式接入执行底座,但默认关闭;当前已经接到 Web 群聊详情页 / 原生群资料页的编排后端选择卡,可在 `Boss Native` 与 `OMX Team` 间切换,OMX 不可用时会自动回退到默认后端并明确提示原因 - 当前仓库已自带 `scripts/omx-team-smoke.mjs` 作为本地 OMX smoke runtime;在没有真实 `oh-my-codex` 可执行文件时,可先用 `BOSS_OMX_COMMAND=node` 与 `BOSS_OMX_ARGS=scripts/omx-team-smoke.mjs` 验证 `dispatch_execution` 的真实执行 contract - 当前主 Agent 对活跃线程的理解已经升级成“线程状态文档 + 最近进展事件 + 关键时刻深拉”:`projectUnderstanding` 不再是唯一输入,`threadStatusDocuments / threadProgressEvents` 已进入主 Agent prompt 主链 @@ -158,8 +162,9 @@ cd /Users/kris/code/boss - `POST /api/v1/accounts/[accountId]/validate` 当前对 `master_codex_node` 不再只看 `nodeId`,还会同时校验绑定设备是否在线;设备离线时返回 `degraded` 和清晰的人类可读提示 - 主 Agent 当前真实对话链路已验证通过:`Boss Web -> /api/v1/projects/master-agent/messages -> master-agent task queue -> local-agent -> codex exec -> /complete -> 项目消息账本` - 主 Agent 单聊当前已改成“快速入队 + 异步回流”:`POST /api/v1/projects/master-agent/messages` 会先返回 `masterReplyState + task`,真实回复随后再回写消息账本 -- 当前对话级 `agentControls` 已经生效:`master-agent` 会话支持 `modelOverride / reasoningEffortOverride`,并会优先作用到实际 OpenAI 回复和 Master Codex Node 执行 prompt -- 当前对话级 `agentControls` 也已支持 `backendOverride`:`master-agent` 会话可在 `Claw Runtime` 可用时显式选择 `claw-runtime`,由 `ExecutionBackendSelector` 在当前对话里优先尝试对应后端;不可用时保存接口会直接拒绝,并返回人类可读原因 +- 当前对话级 `agentControls` 已经生效:`master-agent` 会话支持 `modelOverride / reasoningEffortOverride` 强制覆盖,也支持 `fastModelOverride / fastReasoningEffortOverride / smartModelOverride / smartReasoningEffortOverride` 这组策略默认值;主 Agent 普通对话默认按 fast 档选模型,深度任务可按 smart 档选模型,手动强制覆盖仍然优先级最高 +- 当前 `group_dispatch_plan / device_import_resolution / attachment_analysis` 三类深度任务已经会把 `smart*` 策略下发到任务队列,并随任务持久化 `executionModel / executionReasoningEffort`;local-agent 执行这类任务时会优先吃任务级模型,不再只依赖本机固定默认模型 +- 当前对话级 `agentControls` 也已支持 `backendOverride`:`master-agent` 会话可在 `Claw Runtime` 或 `Hermes Runtime` 可用时显式选择 `claw-runtime / hermes-runtime`,普通单线程会话当前只开放 `hermes-runtime`;不可用时保存接口会直接拒绝,并返回人类可读原因 - 原生 Android 当前会把 `master-agent` 的等待态保留在消息流里:发送后常驻显示“主 Agent 思考中”,超时后改成“主 Agent 回复超时 + 重试等待”,收到新回复后会自动清掉,不再只靠 toast 提示 - `GET /api/v1/app-logs` 当前已支持登录态分页查询 - `POST /api/v1/app-logs`、`POST /api/v1/devices/[deviceId]/skills`、`POST /api/v1/workers/[workerId]/thread-context` 当前都要求有效设备 token 或匹配登录会话 diff --git a/docs/architecture/hermes_runtime_接入与运维指南_cn.md b/docs/architecture/hermes_runtime_接入与运维指南_cn.md new file mode 100644 index 0000000..9553e13 --- /dev/null +++ b/docs/architecture/hermes_runtime_接入与运维指南_cn.md @@ -0,0 +1,472 @@ +# Hermes Runtime 接入与运维指南 + +更新时间:`2026-04-14` + +## 1. 文档目标 + +这份文档只回答一个问题: + +- Boss 当前已经接入的 `hermes-runtime`,本地和服务器应该怎么配、怎么验、怎么运维 + +它面向两类人: + +- 开发者:想先在本机把 Hermes 链路跑通 +- 运维:想把 Hermes 作为 Boss 的一个可选执行后端部署到稳定环境 + +这不是长期架构文档。长期方向请看: + +- `docs/architecture/hermes_对_boss_主agent长期融合评估_cn.md` + +## 2. 当前实现边界 + +当前 Boss 对 Hermes 的接入边界很明确: + +- Hermes 现在是 `ExecutionBackend` 下的一个可选后端,`backendId=hermes-runtime` +- 当前接入方式是外部 CLI 调用,不直接 import Hermes Python 代码 +- 当前支持的请求类型只有: + - `master_agent_reply` + - `thread_reply` +- 当前不支持: + - `dispatch_execution` + - 群聊编排 + - 会话级 session resume + - Honcho / ACP / API server 深融合 + +Boss 当前对 Hermes 的固定调用形态是: + +```text + chat -q -Q --source +``` + +可选追加: + +- `-m ` +- `-t ` +- `-s ` + +Boss 会自动剥掉 Hermes quiet 输出末尾的: + +```text +session_id: ... +``` + +只把正文写回 Boss 对话账本。 + +## 3. 环境变量说明 + +当前 Hermes 接入读取以下环境变量。 + +### 3.1 核心开关 + +#### `BOSS_HERMES_ENABLED` + +- 含义:是否启用 Hermes Runtime +- 可选值:`true | false` +- 默认值:`false` + +只有为 `true` 时,Boss 才会把 Hermes 纳入可选后端探测。 + +#### `BOSS_HERMES_COMMAND` + +- 含义:启动 Hermes 的主命令 +- 默认值:`hermes` + +常见写法: + +- `hermes` +- `uv` +- `python3` +- `node` + +说明: + +- 如果你已经把 Hermes 安装成全局命令,直接用 `hermes` +- 如果你用 `uv run ...` 启动 Hermes,就把命令写成 `uv` +- 如果你通过脚本入口启动,就把命令写成对应运行时,例如 `python3` 或 `node` + +#### `BOSS_HERMES_ARGS` + +- 含义:追加在 `BOSS_HERMES_COMMAND` 后、`chat -q ...` 前面的前缀参数 +- 默认值:空 + +例子: + +```bash +BOSS_HERMES_ARGS="run --directory /opt/hermes-agent hermes" +``` + +最终实际执行会变成: + +```bash +uv run --directory /opt/hermes-agent hermes chat -q "..." -Q --source tool +``` + +如果你是脚本入口,也可以这样写: + +```bash +BOSS_HERMES_COMMAND=python3 +BOSS_HERMES_ARGS="/opt/hermes-agent/cli.py" +``` + +### 3.2 运行目录与超时 + +#### `BOSS_HERMES_WORKDIR` + +- 含义:Hermes 运行时工作目录 +- 默认值:未设置 + +推荐: + +- 本地开发:设置成 Boss 工作区或 Hermes 工作区 +- 服务器:显式设置,不依赖 `process.cwd()` + +#### `BOSS_HERMES_TIMEOUT_MS` + +- 含义:单次 Hermes CLI 调用的超时毫秒数 +- 默认值:`120000` + +建议: + +- 本地 smoke:`5000` 到 `15000` +- 日常开发:`30000` 到 `120000` +- 生产服务:不要无限拉长,先用 `60000` 到 `120000` + +### 3.3 模型、toolsets、skills + +#### `BOSS_HERMES_DEFAULT_MODEL` + +- 含义:当前对话未显式覆盖模型时,Boss 传给 Hermes 的默认模型名 +- 默认值:无 + +#### `BOSS_HERMES_TOOLSETS` + +- 含义:逗号分隔的 Hermes toolsets +- 示例:`web,terminal` + +Boss 会转成: + +```bash +-t web,terminal +``` + +#### `BOSS_HERMES_SKILLS` + +- 含义:逗号分隔的 Hermes skills +- 示例:`boss-dev,github` + +Boss 会转成: + +```bash +-s boss-dev,github +``` + +#### `BOSS_HERMES_SOURCE_TAG` + +- 含义:传给 Hermes 的 `--source` 标记 +- 默认值:`tool` + +通常保持默认即可。 + +## 4. 可用性探测规则 + +Boss 当前对 Hermes 的可用性探测是保守模式。 + +它会检查: + +1. `BOSS_HERMES_ENABLED=true` +2. `BOSS_HERMES_COMMAND` 可执行 +3. 如果设置了 `BOSS_HERMES_WORKDIR`,目录必须存在 +4. 如果命令是脚本运行时: + - `node` + - `tsx` + - `bun` + - `deno` + - `python` + - `python3` + 那么 `BOSS_HERMES_ARGS` 的第一个参数会被视为脚本路径,并检查该脚本是否存在 + +因此有两个实践建议: + +- 用全局 `hermes` 命令时,最省心 +- 用脚本入口时,第一段参数一定要是真实脚本路径,不要把不存在的包装路径塞进去 + +## 5. 本地 Smoke 验证 + +这是当前最推荐的第一步,因为它不依赖真实 Hermes 环境。 + +### 5.1 环境变量 + +在 Boss 工作区里设置: + +```bash +export BOSS_HERMES_ENABLED=true +export BOSS_HERMES_COMMAND=node +export BOSS_HERMES_ARGS=scripts/hermes-runtime-smoke.mjs +export BOSS_HERMES_WORKDIR=/Users/kris/code/boss +export BOSS_HERMES_TIMEOUT_MS=5000 +``` + +### 5.2 启动 Boss + +```bash +cd /Users/kris/code/boss +npm run build +npm start +``` + +### 5.3 验证方式 + +验证点有三层: + +1. `GET /api/v1/projects/master-agent/agent-controls` + - 应该能看到 `hermesAvailability.selectable=true` +2. 在 `master-agent` 会话里保存 `backendOverride=hermes-runtime` +3. 给 `master-agent` 或普通单线程会话发消息 + - 应该看到任务进入 `queued` + - 随后回写 smoke 内容 + +如果只想测后端契约,不测前端,也可以直接跑测试: + +```bash +npx tsx --test tests/hermes-backend-config.test.ts tests/hermes-runner.test.ts tests/hermes-backend.test.ts +``` + +## 6. 真实 Hermes CLI 接入 + +当前推荐两种方式。 + +### 6.1 方式 A:系统里已经有 `hermes` 命令 + +适合: + +- 已经通过官方推荐方式安装 Hermes +- `PATH` 里直接能找到 `hermes` + +示例: + +```bash +export BOSS_HERMES_ENABLED=true +export BOSS_HERMES_COMMAND=hermes +export BOSS_HERMES_WORKDIR=/opt/hermes-workspace +export BOSS_HERMES_TIMEOUT_MS=60000 +export BOSS_HERMES_DEFAULT_MODEL=gpt-5.4 +export BOSS_HERMES_TOOLSETS=web,terminal +``` + +优点: + +- 配置最简单 +- 可用性探测最稳定 + +缺点: + +- 依赖系统环境已经正确安装 Hermes + +### 6.2 方式 B:通过 `uv run` 进入 Hermes 仓库 + +适合: + +- 不想做全局安装 +- 希望固定某个 Hermes checkout 或虚拟环境 + +示例: + +```bash +export BOSS_HERMES_ENABLED=true +export BOSS_HERMES_COMMAND=uv +export BOSS_HERMES_ARGS="run --directory /opt/hermes-agent hermes" +export BOSS_HERMES_WORKDIR=/opt/hermes-workspace +export BOSS_HERMES_TIMEOUT_MS=60000 +export BOSS_HERMES_DEFAULT_MODEL=gpt-5.4 +``` + +优点: + +- 上游版本更可控 +- 不污染全局命令 + +缺点: + +- `uv` 自身必须已安装 +- `BOSS_HERMES_ARGS` 更容易配错 + +## 7. 服务器侧推荐做法 + +Boss 当前服务器是: + +- 主机:`106.53.170.158` +- 代码路径:`/opt/boss` + +如果要在服务器上给 Boss 打开 Hermes,推荐这样做。 + +### 7.1 目录分离 + +不要把 Hermes 仓库直接塞进 Boss 仓库里。 + +推荐: + +- Boss 代码:`/opt/boss` +- Hermes 代码:`/opt/hermes-agent` +- Hermes 工作目录:`/opt/hermes-workspace` + +原因: + +- 便于各自升级 +- 避免把 Boss 构建、部署、权限模型和 Hermes 混在一起 + +### 7.2 运行用户一致 + +Boss 当前服务是 `ubuntu` 用户启动。 + +Hermes 也尽量用同一运行用户准备环境,避免: + +- `boss-web.service` 能启动 Boss,但找不到 Hermes 环境 +- `uv` 或虚拟环境只在 root 下可用 +- 工作目录权限错乱 + +### 7.3 systemd 环境显式注入 + +如果是服务器长期运行,不要只靠 shell profile。 + +推荐把以下变量写进 `boss-web.service` 的环境或 `.env.server`: + +```bash +BOSS_HERMES_ENABLED=true +BOSS_HERMES_COMMAND=uv +BOSS_HERMES_ARGS=run --directory /opt/hermes-agent hermes +BOSS_HERMES_WORKDIR=/opt/hermes-workspace +BOSS_HERMES_TIMEOUT_MS=60000 +``` + +### 7.4 先做 smoke,再切真实 runtime + +推荐顺序: + +1. 先用 `scripts/hermes-runtime-smoke.mjs` 验证 Boss 侧链路 +2. 再切到真实 Hermes CLI +3. 再让 `master-agent` 选择 `hermes-runtime` +4. 最后再放开普通线程的小范围试用 + +不要一上来就在服务器上直接切真实 Hermes,然后把问题混在: + +- Boss 路由 +- 环境变量 +- Hermes 安装 +- 模型配置 +- toolsets +- 权限问题 + +## 8. 不建议的做法 + +当前阶段,明确不建议以下做法。 + +### 8.1 不建议把 Hermes 当 Boss 的主运行真相 + +不要把这些直接交给 Hermes: + +- 会话账本 +- 群聊成员真相 +- 审批状态 +- 设备导入状态 +- 项目/线程业务归属 + +这些必须继续留在 Boss。 + +### 8.2 不建议直接依赖 Hermes SQLite 或 ACP 会话作为 Boss 会话真相 + +原因: + +- Boss 的项目/线程/群聊模型和 Hermes session 模型不是一回事 +- 当前 Boss 还没有 session binding 抽象 +- ACP 会话也不是 Boss 的业务真相 + +### 8.3 不建议第一批就打开高风险 toolsets + +如果只是验证 Hermes 接入,不要一开始就把所有工具都打开。 + +先用最小集合: + +- 可只开基础聊天 +- 如果确实要工具,先开低风险 toolsets + +原因: + +- 当前 Boss 还没有完整的 `PermissionPolicy -> runtime policy` 映射 +- 先验证后端链路,再扩大能力面更稳 + +### 8.4 不建议把 Hermes 和 local-agent 混成同一执行器 + +当前普通线程默认路径仍然是: + +```text +Boss -> queue conversation_reply -> local-agent -> codex exec resume -> complete +``` + +只有显式设置 `backendOverride=hermes-runtime` 时,才会切到 Hermes 占位任务路径。 + +不要把这两条链未经抽象就揉成一个执行器,否则后面很难排查问题。 + +## 9. 推荐排障顺序 + +如果 `Hermes Runtime` 在前台不可选,按这个顺序查。 + +### 9.1 先查可用性 + +看接口返回: + +- `GET /api/v1/projects/master-agent/agent-controls` +- `GET /api/v1/projects/master-agent/prompt-profile` + +重点字段: + +- `hermesAvailability.status` +- `hermesAvailability.reason` +- `hermesAvailability.reasonLabel` + +### 9.2 再查命令本身能不能跑 + +例如: + +```bash +hermes --help +``` + +或: + +```bash +uv run --directory /opt/hermes-agent hermes --help +``` + +### 9.3 再查 Boss 最终会拼成什么命令 + +当前 Boss 固定会在你的前缀后补: + +```bash +chat -q "" -Q --source tool +``` + +所以要确认你的命令前缀真的兼容这种拼法。 + +### 9.4 最后查任务回写 + +如果前台已经能选 Hermes,但消息没有回流: + +- 查 `masterAgentTasks` +- 查 `/api/v1/master-agent/tasks/[taskId]/complete` +- 查 Boss 日志里是否是 Hermes 执行失败后回退到别的后端 + +## 10. 当前推荐结论 + +如果你现在要让 Boss 用 Hermes,推荐顺序是: + +1. 先用 `scripts/hermes-runtime-smoke.mjs` 验证 Boss 契约 +2. 再接真实 Hermes CLI +3. 先只给 `master-agent` 打开 +4. 再按项目给普通单线程会话灰度打开 `backendOverride=hermes-runtime` +5. 群聊编排、session resume、Honcho、ACP、API server 后续再做 + +当前阶段最稳的定位仍然是: + +- Boss 负责产品真相 +- Hermes 负责可替换执行 runtime + diff --git a/docs/architecture/hermes_对_boss_主agent长期融合评估_cn.md b/docs/architecture/hermes_对_boss_主agent长期融合评估_cn.md new file mode 100644 index 0000000..f15d2f1 --- /dev/null +++ b/docs/architecture/hermes_对_boss_主agent长期融合评估_cn.md @@ -0,0 +1,772 @@ +# Hermes 对 Boss 主 Agent / 业务流的长期融合评估 + +更新时间:`2026-04-14` + +## 1. 文档目标 + +这份文档回答四个问题: + +1. Hermes 对 Boss 主 Agent 的长期参考价值是什么 +2. 哪些能力值得抽象进 Boss 内核,哪些不要借 +3. 第二阶段与第三阶段建议路线 +4. 风险、边界和升级策略 + +这不是一份“把 Hermes 整体接进 Boss”的实施说明,而是一份长期架构判断文档。结论基于以下输入: + +- Boss 当前运行真相与既有融合策略: + - `docs/architecture/boss_external_runtime_fusion_strategy_cn.md` + - `docs/architecture/current_runtime_and_deploy_status_cn.md` + - `docs/architecture/api_and_service_inventory_cn.md` + - `docs/superpowers/specs/2026-04-14-hermes-backend-mvp-design.md` +- Boss 当前 Hermes MVP 实现: + - `src/lib/execution/backends/hermes-config.ts` + - `src/lib/execution/backends/hermes-runner.ts` + - `src/lib/execution/backends/hermes-backend.ts` + - `src/lib/execution/backend-selector.ts` + - 对应 Web 控制与测试文件 +- Hermes 官方公开文档: + - README: `https://raw.githubusercontent.com/NousResearch/hermes-agent/main/README.md` + - Quickstart: `https://hermes-agent.nousresearch.com/docs/getting-started/quickstart/` + - Architecture: `https://hermes-agent.nousresearch.com/docs/developer-guide/architecture/` + - Session Storage: `https://hermes-agent.nousresearch.com/docs/developer-guide/session-storage/` + - Tools & Toolsets: `https://hermes-agent.nousresearch.com/docs/user-guide/features/tools/` + - ACP: `https://hermes-agent.nousresearch.com/docs/user-guide/features/acp/` + - API Server: `https://hermes-agent.nousresearch.com/docs/user-guide/features/api-server/` + - Security: `https://hermes-agent.nousresearch.com/docs/user-guide/security/` + - Honcho Memory: `https://hermes-agent.nousresearch.com/docs/user-guide/features/honcho/` + +## 2. 证据边界 + +本次评估有一个必须显式写清楚的边界: + +- 用户指定优先阅读的 `/tmp/hermes-agent/README.md`、`/tmp/hermes-agent/docs/acp-setup.md`、`/tmp/hermes-agent/docs/honcho-integration-spec.md` 在当前工作机上不存在,未能读取。 +- 因此,本文对 Hermes 的判断以 Boss 仓库内已落地的 Hermes MVP 实现,加上 Hermes 官方公开文档为主。 +- 这意味着本文对“上游最近几次提交中的未发布细节”不做强判断,只对已经稳定公开、且确实会影响 Boss 长期架构边界的能力做判断。 + +换句话说,这份文档适合指导 Boss 第二阶段、第三阶段的融合方向,但不应被当成对 Hermes 私有或未发布能力的最终定论。 + +## 3. 当前状态判断 + +### 3.1 Boss 侧当前已经做了什么 + +Boss 已经不是“讨论要不要接 Hermes”的阶段,而是已经完成了一个最小接入: + +- 在 `src/lib/execution/` 下已经形成稳定执行底座 +- Hermes 当前以 `hermes-runtime` 的形式进入 `ExecutionBackend` +- 接入范围目前只覆盖 `master-agent` 的执行后端选择 +- 运行方式是外部命令调用,不 import Hermes Python 代码 +- 当前契约本质上仍是 one-shot: + - Boss 组装 prompt + - 调用 Hermes CLI + - 读取 stdout + - 去掉 `session_id` + - 将正文作为回复写回 Boss + +这说明 Boss 当前的判断是对的:先把 Hermes 放在执行层适配器位置,而不是把它当成 Boss 产品层替代物。 + +### 3.2 Hermes 上游真正强在哪里 + +基于官方文档,Hermes 的强项不是 Boss 已经做得好的产品域,而是以下几层: + +- 成熟 agent loop + - Hermes 官方架构文档明确把核心对话循环、工具注册、provider 解析、session 存储、gateway、ACP 都做成了一套统一 runtime。 +- 丰富的工具与 toolset 体系 + - 官方架构文档写明 Hermes 当前注册了 47 个工具、19 个 toolset,terminal 工具支持 6 种 backend;官方工具文档同时说明这些能力可以按平台启停,并支持 MCP 动态扩展。 +- 持久 session 与可检索历史 + - 官方 session storage 文档说明 Hermes 用 SQLite + FTS5 持久化会话元数据、消息历史和 lineage。 +- 多入口但共享同一运行时 + - CLI、gateway、ACP、API server 共用 provider / tool / state 基础设施;其中 API server 还可以把 Hermes 暴露成 OpenAI-compatible HTTP endpoint。 +- 安全和审批模型 + - 官方 security 文档明确有危险命令审批、容器隔离、MCP 凭据过滤、context file 扫描。 +- 长期记忆体系 + - 官方 Honcho 文档说明 Hermes 不只有本地 memory 文件,还有 memory provider plugin;Honcho 以插件形式提供更深的用户建模和跨 session 结论归纳。 +- 子代理与并行执行 + - README 和工具文档都把 delegation、code execution、cron 作为一等能力。 + +因此,Hermes 对 Boss 的长期价值,主要不是“多一个能跑的 CLI”,而是“提供一套已经在执行层、会话层、工具层、权限层成型的 agent runtime 参考系”。 + +## 4. 总结论 + +一句话结论: + +- Hermes 对 Boss 主 Agent 的长期参考价值很高 +- 但它更适合作为 Boss 的“执行内核候选 + 运行时能力参考系” +- 不适合作为 Boss 的“产品骨架、业务状态源或编排真相” + +进一步展开: + +### 4.1 Hermes 的长期参考价值是什么 + +Hermes 对 Boss 的长期价值主要有五层。 + +#### 第一层:验证 Boss 的执行抽象方向是对的 + +Boss 已经抽出了 `ExecutionBackend / PromptAssembler / PermissionPolicy / RemoteRuntimeAdapter / OrchestrationBackend`。Hermes 官方架构恰好证明了这条路是对的: + +- provider 解析可以独立 +- tool registry 可以独立 +- session persistence 可以独立 +- UI / gateway / ACP 只是不同入口,不应倒逼业务层重塑 + +这意味着 Boss 继续把“业务层稳定、运行时可替换”做深,是有上游现实参照的。 + +#### 第二层:给 Boss 主 Agent 提供更强的执行上限 + +Boss 当前默认主链仍偏“Boss 产品逻辑 + 外部执行通道”。Hermes 提供的是一个更完整的 agent runtime: + +- 工具选择能力更强 +- 文件 / 终端 / 浏览器 / MCP / delegation 能力更全 +- 记忆、session search、skills、approval 都在一个 runtime 内 + +对 Boss 主 Agent 来说,这意味着未来可以把复杂任务逐步从“Boss 先做大量业务编排,再找后端执行一句 prompt”提升到“Boss 给出业务边界,Hermes 在边界内自主完成更多工作”。 + +#### 第三层:帮助 Boss 定义“会话级执行”的中间层 + +Boss 当前 Hermes MVP 还是 one-shot,但 Hermes 官方文档显示它天然具备: + +- session 持久化 +- lineage +- ACP 会话管理 +- API server +- gateway 统一 routing + +这对 Boss 很重要,因为 Boss 以后一定会遇到“主 Agent 到底是一次性回答,还是持续工作中的 agent session”这个问题。Hermes 不能直接成为 Boss 的 session 真相,但它是一个很好的 session 能力参考实现。需要额外注意的是,官方 ACP 文档明确说明 ACP 的 `list/load/resume/fork` 只在当前 ACP server 进程存活期间有效,这再次证明它更适合作为执行入口参考,而不是 Boss 的长期会话真相。 + +#### 第四层:为 Boss 的工具能力治理提供成熟参照 + +Hermes 的 toolset 分组、审批、安全边界、terminal backend 分层都很成熟。Boss 不一定要照搬工具名和实现,但值得吸收它的治理模型: + +- 工具不是平铺权限,而是按风险级别和运行场景分层 +- 本地、容器、SSH、云执行是同一工具能力的不同 backend +- 审批与持久 allowlist 是 runtime 级能力,而不是前台零散开关 + +这正是 Boss 后续把主 Agent 从“能说”升级到“能稳定干活”时最需要的部分。 + +#### 第五层:提供“可升级外部 runtime”的实际样板 + +Hermes 不只是功能点集合,它还是一个活跃上游。Boss 如果能把 Hermes 视作“外部 runtime 插件族”之一,而不是一次性集成项目,那么 Boss 未来可以用相同方法吸收: + +- Hermes +- Claw +- OMX +- 其他 ACP / API / CLI agent runtime + +这会让 Boss 逐步形成自己的执行生态,不再被某一个上游实现绑死。 + +## 5. 哪些能力值得抽象进 Boss 内核 + +这里的原则不是“哪个能力厉害就搬哪个”,而是: + +- 只抽象 Boss 必须长期拥有主导权的层 +- 只抽象对多个 runtime 都成立的层 +- 只抽象不会把 Boss 业务真相外包给 Hermes 的层 + +### 5.1 值得抽象进 Boss 内核的能力 + +#### 5.1.1 Runtime Capability Profile + +Boss 现在已经有 backend selectable / availability,但还不够系统。应该继续抽象成统一的 runtime capability profile,例如: + +- 是否支持 one-shot +- 是否支持 session resume +- 是否支持 delegation +- 是否支持 toolsets +- 是否支持 structured approval +- 是否支持 remote terminal backend +- 是否支持 memory provider +- 是否支持 MCP + +原因: + +- 这是 Boss 做“后端选择、灰度、回退、UI 呈现”的稳定基础 +- 这层应该由 Boss 控制,不应把 Hermes 的内部概念直接泄漏到前台 + +#### 5.1.2 Session Binding Interface + +Boss 现在把 Hermes `session_id` 去掉是对的,但长期不能永远停在这里。应该抽象一层通用 session binding interface,而不是 Hermes 专用字段: + +- `createSession` +- `resumeSession` +- `closeSession` +- `sessionMetadata` + +原因: + +- 未来不止 Hermes 会有 session +- Boss 需要控制“哪个业务项目/线程绑定哪个 runtime session” +- 但 session 的生命周期必须由 Boss 业务事件驱动,而不是由 Hermes 自己的 SQLite 状态反向决定 + +#### 5.1.3 Tool Capability Registry + +Boss 现在有 `ToolRegistry` 雏形,但后续应该升级成“工具能力注册表”,重点不是工具实现,而是能力治理: + +- 能力类别 +- 风险等级 +- 是否需要审批 +- 哪些 runtime 支持 +- 哪些业务场景允许 + +原因: + +- Hermes 的 toolsets 给了很好的上游样板 +- Boss 必须把“业务权限”与“runtime 工具能力”中间再插一层自己的治理 + +#### 5.1.4 Permission Policy 到 Runtime Policy 的映射层 + +Hermes security 文档说明审批、安全扫描、allowlist 是 runtime 一等能力。Boss 也需要形成自己的 policy mapping 层: + +- Boss 业务策略: + - 这个项目允许不允许写文件 + - 当前群聊是否允许直接 dispatch + - 当前设备是否允许远程终端 +- Runtime 执行策略: + - Hermes toolsets 开哪些 + - Hermes terminal backend 用本地还是 SSH + - 需要哪些审批模式 + +原因: + +- 这是 Boss 产品真相和 Hermes runtime 真相之间最关键的隔离层 + +#### 5.1.5 记忆分层接口 + +Boss 不应该直接吃 Hermes 的记忆文件或 Honcho 结构,但应该把“Boss 业务记忆”和“外部 runtime 记忆能力”拆分成两层: + +- Boss 业务记忆: + - 项目目标 + - 项目记忆 + - 线程状态文档 + - 最近进展事件 + - 用户级主 Agent 偏好 +- 外部 runtime 记忆: + - Hermes local memory + - session search + - Honcho profile / context + +Boss 内核需要的是统一接口,而不是固定供应商实现。 + +#### 5.1.6 Runtime Health / Fallback / Telemetry + +Hermes 当前已经接入 availability 与 fallback,但长期应升级成统一能力: + +- 启动健康探测 +- capability 探测 +- 最近失败原因 +- 版本信息 +- smoke 测试结果 +- 自动回退规则 + +原因: + +- 这决定 Boss 是否能安全地长期吸收外部 runtime 的升级成果 + +### 5.2 有条件再考虑、但现在不该内核化的能力 + +#### 5.2.1 Delegation / Subagent Contract + +Hermes 的 delegation 很强,但 Boss 现在不该直接把“子代理体系”写死进内核。更合理的做法是: + +- 第二阶段只把它视为 runtime capability +- 第三阶段再评估是否需要 Boss 自己定义统一 subagent contract + +因为一旦定义过早,就容易被某个 runtime 的行为模型绑死。 + +#### 5.2.2 Memory Provider Plugin + +Hermes 已经有 memory provider plugin 和 Honcho。Boss 长期可以参考“memory provider 可插拔”这个方向,但不建议在第二阶段就把 Boss 记忆系统插件化。当前 Boss 的项目理解、线程状态文档、进展事件还在快速演化,过早插件化会锁死模型。 + +## 6. 哪些能力不要借,或者只可作为外部能力存在 + +这里是最重要的边界。Hermes 很强,但 Boss 不能被 Hermes 反向塑形。 + +### 6.1 不要借来当 Boss 产品骨架的能力 + +#### 6.1.1 Gateway / 多平台消息入口 + +Hermes 有 CLI、Telegram、Discord、Slack、WhatsApp、Signal、Email、API server、ACP 等多入口能力,但 Boss 不应该把这些入口当作自己的前台路线。 + +原因: + +- Boss 已经有 Web / Android / local-agent / 群聊 / 审批 / 设备导入等产品主链 +- 如果把 Hermes gateway 当成 Boss 主入口,产品模型会被即时通讯平台语义反向塑形 + +判断: + +- 可以作为外部执行入口参考 +- 不应该成为 Boss 主业务入口 + +#### 6.1.2 Hermes 的 SQLite Session Store + +Hermes 官方 session storage 很成熟,但 Boss 不能把它当成业务真相库。 + +原因: + +- Boss 的真相不是“agent 对话历史”这么简单 +- Boss 还有项目、线程、设备、审批、群聊、导入、记忆、账本、投影视图 +- 一旦让 Hermes state.db 成为真实会话源,Boss 会失去对业务生命周期的主导权 + +判断: + +- 可以参考 schema 和 lineage 思路 +- 不能作为 Boss 核心账本或主状态库 + +#### 6.1.3 Honcho 用户建模作为 Boss 主记忆 + +Honcho 的优势是跨 session、跨设备、跨 peer 的用户建模,但 Boss 不能直接拿它替代自己的主 Agent 记忆体系。 + +原因: + +- Boss 关心的是业务记忆,不只是人格/偏好/沟通风格 +- Honcho 适合“用户画像 + 深层偏好” +- Boss 更需要“项目事实、线程事实、设备事实、审批事实” + +判断: + +- 可以作为外部补充记忆源 +- 不能成为 Boss 记忆主真相 + +#### 6.1.4 ACP Session Manager 语义 + +Hermes ACP 文档明确说 ACP 会话只在 ACP server 进程存活期间有效,`list/load/resume/fork` 也限定在该 server 生命周期内。 + +这对 Boss 来说只能作为编辑器集成参考,不能直接拿来做主 Agent 长期会话模型。 + +原因: + +- Boss 会话生命周期是业务驱动,不是 editor server 驱动 +- ACP 进程级 session manager 太短命 + +#### 6.1.5 Cron 与平台投递系统 + +Hermes 自带 cron 调度和任意平台投递,但 Boss 当前不应该把这部分纳入主 Agent 内核。 + +原因: + +- Boss 当前最核心的问题是执行层、业务状态、审批和项目理解,不是调度器丰富度 +- 过早接入 cron 会把“任务调度”和“业务执行”耦合 + +判断: + +- 可作为未来运维或后台任务参考 +- 不应进入第二阶段主线 + +### 6.2 不要照搬的设计风格 + +#### 6.2.1 不要把 Hermes 的工具名和 slash command 暴露成 Boss UI 概念 + +Boss 前台不应该出现: + +- `toolsets` +- `session_search` +- `honcho_profile` +- `delegate_task` +- `allow once / allow always` + +这类原生 Hermes 概念可以存在于适配层和运维界面,但不应直接成为 Boss 面向用户的产品语言。 + +#### 6.2.2 不要让 Hermes 的 context files 决定 Boss 提示词层级 + +Hermes 有自己的 context files / skills / memory 注入方式。Boss 不能因此削弱现有的: + +- 管理员全局主提示词 +- 用户私有主提示词 +- 当前对话附加提示词 +- 项目记忆 +- 线程状态文档 + +Boss 的提示词优先级必须继续由 Boss 掌控。 + +## 7. 第二阶段建议路线 + +第二阶段的关键词不是“接更多功能”,而是“把 Hermes 从最小可用后端升级成可长期运营的受控后端”。 + +### 7.1 第二阶段目标 + +目标应收敛为三件事: + +1. 把 Hermes 从 demo 级 one-shot backend 升级为受控运行时后端 +2. 让 Boss 对 Hermes 的能力感知、配置、回退、观测更完整 +3. 仍然不让 Hermes 进入 Boss 编排层与业务真相层 + +### 7.2 第二阶段建议任务 + +#### 7.2.1 补齐 Runtime Capability Profile + +为 Hermes 增加稳定 capability 描述,而不是只暴露 `selectable / reasonLabel`: + +- `supportsOneShot` +- `supportsSessionResume` +- `supportsDelegation` +- `supportsToolsets` +- `supportsMemoryProvider` +- `supportsMcp` +- `supportsApprovalBridge` +- `supportsRemoteTerminal` + +这样 Boss 后续前台、策略、实验、灰度都能统一处理。 + +#### 7.2.2 引入最小 Session Metadata 捕获,但不直接业务化 + +建议第二阶段开始捕获 Hermes `session_id`,但只作为内部诊断元数据,不立刻绑定业务主链: + +- 记录最近一次执行返回的 `session_id` +- 记录 Hermes command/model/toolsets/skills +- 记录运行耗时、退出码、fallback 原因 + +不做: + +- 不做 Boss 项目线程到 Hermes session 的强绑定 +- 不做 resume 主链 + +这样能为第三阶段做准备,但不会过早扩大范围。 + +#### 7.2.3 做一层 Boss -> Hermes policy mapping + +把 Boss 的业务约束映射成 Hermes 运行时约束: + +- 项目级只读 / 可写 +- 是否允许 terminal +- 是否允许 web / browser +- 是否允许 delegation +- 是否允许外部 MCP + +第二阶段可以先从静态配置开始,不必一开始就做动态全量映射。 + +#### 7.2.4 建立 Hermes 专属 smoke / health / version 检查 + +当前 availability 只检查命令、cwd、脚本存在。第二阶段应该加入: + +- `hermes --version` 或等价 version check +- 最小真实 prompt smoke +- toolset 启动 smoke +- provider 可用性检查 +- 可选的 API server / ACP 旁路探测 + +这会把“能选”升级成“可运营”。 + +#### 7.2.5 限定场景开展 A/B 对照实验 + +第二阶段只建议在主 Agent 的少数高价值场景做 Hermes 对照: + +- 长提示词理解 +- 需要文件/终端/浏览器联动的问题 +- 需要多步工具执行的问题 +- 需要更强自主探索的问题 + +不要一上来把所有主 Agent 请求默认切到 Hermes。 + +### 7.3 第二阶段明确不做 + +- 不接 `dispatch_execution` +- 不接群聊编排 +- 不接设备导入审核链 +- 不接 Honcho 作为 Boss 默认记忆 +- 不接 ACP 成为 Boss 主前台 +- 不把 Hermes API server 暴露成 Boss 对外 API 主链 + +## 8. 第三阶段建议路线 + +第三阶段的关键词是“选择性深化”,不是“全面融合”。 + +### 8.1 第三阶段前提 + +只有在第二阶段满足以下条件后,才建议进入第三阶段: + +- Hermes 后端运行稳定 +- fallback 和 kill switch 可靠 +- Boss 已形成 capability / policy / telemetry 的统一抽象 +- 已明确哪些任务在 Hermes 上明显优于当前默认后端 + +### 8.2 第三阶段可以做什么 + +#### 8.2.1 引入 Session-Aware Backend Contract + +如果第二阶段观察证明 Hermes 在持续任务里明显更有优势,第三阶段可以升级为 session-aware backend: + +- Boss 为项目或线程保存 runtime session binding +- 支持显式 resume / compact / close +- 主 Agent 在少数项目上变成“持续运行的工作线程” + +但必须坚持: + +- Boss 决定何时创建、恢复、关闭 +- Hermes 不直接拥有业务生命周期 + +#### 8.2.2 选择性开放 Delegation + +第三阶段可以考虑让 Hermes delegation 进入 Boss,但只作为受限能力: + +- 只在主 Agent 内部使用 +- 只对特定项目开放 +- 必须经过 Boss policy 层约束 +- 子代理产物回写必须标准化 + +换句话说,Boss 可以借 Hermes 的“多步执行能力”,但不能把 Boss 的编排主权交给 Hermes。 + +#### 8.2.3 引入可插拔 Memory Provider Adapter + +第三阶段可以评估把 Honcho 或其他 memory provider 作为外部补充记忆源接入: + +- 用于用户偏好、工作习惯、长期画像 +- 不用于项目事实和审批真相 + +Boss 应保持“双层记忆”: + +- Boss 事实记忆 +- 外部推理型记忆 + +#### 8.2.4 评估 ACP / API Server 作为受控旁路入口 + +如果未来 Boss 需要编辑器直连、桌面 IDE 集成或本地私有 agent service,第三阶段可以评估: + +- ACP 作为开发态集成通道 +- Hermes API server 作为受控本地后端 + +但必须保持: + +- Boss 是业务真相层 +- ACP / API server 只是执行入口 + +### 8.3 第三阶段仍然不要做什么 + +- 不让 Hermes 替代 Boss 群聊编排 +- 不让 Hermes 替代 Boss 审批流 +- 不让 Hermes 替代 Boss 设备导入逻辑 +- 不让 Hermes state.db 替代 Boss 状态账本 +- 不让 Hermes gateway 替代 Boss Web / Android 产品层 + +## 9. 风险与边界 + +### 9.1 最大风险:上下文主权混乱 + +Boss 有自己的 prompt、memory、project understanding、thread status。Hermes 也有: + +- system prompt +- skills +- MEMORY / USER +- Honcho +- context files +- session search + +如果不加边界,很容易出现: + +- Boss 明明要求 A +- Hermes 自己的 memory / skill / context file 又暗示 B +- 最终输出谁在主导不清楚 + +所以必须坚持: + +- Boss 决定业务上下文主权 +- Hermes 只在 Boss 明确授权的范围内做 agentic 执行 + +### 9.2 第二大风险:权限模型不一致 + +Boss 的审批流是业务审批,Hermes 的 approval 是 runtime 审批。这两者不是一回事。 + +如果直接混用,会出现: + +- Boss 以为“已批准执行” +- 但 Hermes 还在等危险命令批准 + +或者: + +- Hermes 因本地 allowlist 放过了命令 +- Boss 却并未允许该业务动作 + +因此必须分层: + +- Boss 负责业务许可 +- Hermes 负责运行时危险动作许可 +- 中间靠 policy mapping 对齐 + +### 9.3 第三大风险:状态与观测割裂 + +如果 Hermes 执行内部发生: + +- 自动记忆写入 +- session 压缩 +- subagent delegation +- tool 失败重试 + +但 Boss 只能看到最终一段 stdout,那么 Boss 无法做审计、归因和用户解释。 + +因此长期必须补: + +- 最小运行元数据 +- 最小工具活动摘要 +- 明确失败分类 + +但这不等于把 Hermes 全量内部日志搬进 Boss。 + +### 9.4 第四大风险:运维依赖扩张 + +Hermes 引入的是一整套 Python runtime、provider 凭据、tool dependencies、MCP、optional extras。对 Boss 来说,这会带来: + +- 安装复杂度增加 +- 服务器镜像和本地设备环境差异 +- 版本升级不一致 +- provider 凭据与 Boss 账号配置割裂 + +所以 Hermes 长期必须维持“可选、可探测、可回退”,不能成为 Boss 单点依赖。 + +### 9.5 第五大风险:成本和上下文膨胀 + +Hermes 的强项也是风险点: + +- 更强工具调用 +- 更多记忆注入 +- session search +- Honcho +- delegation + +这些能力如果没有边界,会直接带来更高 token 成本和更长响应尾延迟。Boss 当前主 Agent 的部分场景并不需要这么重。 + +## 10. 升级策略 + +### 10.1 采用“能力 checkpoint”而不是“整仓跟随” + +不要用“Boss 跟随 Hermes 最新版”这种策略,而要用能力 checkpoint: + +1. 选定一个 Hermes 版本 +2. 验证一组 Boss 关心的能力: + - CLI one-shot + - model/provider 解析 + - toolsets + - approvals + - session metadata + - optional MCP +3. 通过后才升级 Boss 适配层 + +这能避免 Boss 被 Hermes 高频演进拖着走。 + +### 10.2 维持严格的单向边界 + +推荐长期坚持以下边界: + +- Boss -> Hermes: + - `ExecutionRequest` + - policy 映射后的 runtime config + - 明确的 prompt 与 memory 注入 +- Hermes -> Boss: + - 标准化结果 + - 最小 session metadata + - 最小 telemetry + +不要让 Boss 直接依赖: + +- Hermes 内部 SQLite schema +- Hermes 内部 Python 类 +- Hermes 内部工具注册过程 + +### 10.3 先扩可观察性,再扩能力 + +每一次升级顺序都应是: + +1. 先补 health / telemetry / fallback +2. 再开放新能力 + +例如: + +- 想接 session resume,先能看见 session 元数据 +- 想接 delegation,先能看见子任务摘要和失败分类 +- 想接 Honcho,先定义 Boss 侧的记忆归属边界 + +### 10.4 始终保留 kill switch + +Hermes 长期必须是可关闭的: + +- 配置级关闭 +- 项目级关闭 +- 运行时自动回退 +- 前台明确提示回退原因 + +这是 Boss 能放心吸收外部 runtime 的前提。 + +### 10.5 用受控试点项目推进,而不是全局切换 + +升级节奏建议: + +- 只在 `master-agent` 的少量项目启用 +- 先对复杂任务启用 +- 按项目或账号白名单试点 +- 保留默认后端作对照 + +Boss 不应该出现“大版本直接切 Hermes 为默认”的动作。 + +## 11. 最终判断 + +### 11.1 长期定位 + +Hermes 在 Boss 中的长期定位应是: + +- 第一身份:执行 runtime 候选 +- 第二身份:运行时抽象设计参考 +- 第三身份:未来 session-aware agent backend 候选 + +而不是: + +- Boss 产品层替代者 +- Boss 编排层替代者 +- Boss 状态账本替代者 + +### 11.2 值得吸收的核心东西 + +Boss 最值得从 Hermes 吸收的,不是某个具体命令,而是这四个长期结构: + +- runtime capability 分层 +- tool / approval / backend 的统一治理 +- session-aware agent runtime 的中间层设计 +- 业务真相与外部 runtime 分离的架构纪律 + +### 11.3 不该动摇的 Boss 主权 + +Boss 必须继续自己掌握: + +- 会话与项目模型 +- 群聊与审批 +- 设备导入 +- 业务记忆与线程状态文档 +- 用户可见产品语言 +- 账本与聚合投影视图 + +Hermes 可以把 Boss 主 Agent 变强,但不应该把 Boss 变成 Hermes 的 UI 外壳。 + +## 12. 建议的下一步 + +建议按优先级做三件事。 + +### 12.1 先做一份第二阶段实施设计 + +主题应收敛为: + +- `Hermes Runtime Capability / Policy / Telemetry 第二阶段设计` + +只覆盖: + +- capability profile +- session metadata 捕获 +- policy mapping +- smoke / health / version 探测 +- telemetry 与 fallback + +不扩大到 dispatch、群聊、Honcho。 + +### 12.2 补齐真实上游证据 + +等 `/tmp/hermes-agent` 可用后,重新核对: + +- `README.md` +- `docs/acp-setup.md` +- `docs/honcho-integration-spec.md` + +重点看是否存在与官方公开文档不一致、且会影响 Boss 边界判断的实现细节。 + +### 12.3 建一个 Hermes 受控试点矩阵 + +至少分三类任务做对照: + +- 纯问答型主 Agent 任务 +- 多步工具执行任务 +- 需要较强上下文整合的复杂任务 + +用实际结果决定 Hermes 在 Boss 中应该停留在“高级可选后端”,还是继续推进到“session-aware 主 Agent runtime”。 diff --git a/docs/superpowers/plans/2026-04-14-android-chat-status-row-plan.md b/docs/superpowers/plans/2026-04-14-android-chat-status-row-plan.md new file mode 100644 index 0000000..cb2b07b --- /dev/null +++ b/docs/superpowers/plans/2026-04-14-android-chat-status-row-plan.md @@ -0,0 +1,112 @@ +# Android Chat Status Row Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Render `conversationTasks` and `executionWarnings` as a compact inline status row beneath each message on the Android chat page, dedupe grouped warnings per message, and keep realtime patches scoped to the affected message. + +**Architecture:** Add a reusable status-row builder in `BossUi` and wire `ProjectDetailActivity.buildMessageView` to compute grouped tasks/warnings, append the status row under each bubble, and compare a fingerprint during realtime patches so only impacted views re-render. + +**Tech Stack:** Android/Java UI (`ProjectDetailActivity.java`, `BossUi.java`) and Node-powered source-verification tests (`tests/android-chat-incremental-realtime-append.test.ts`, `tests/android-chat-local-realtime-patch.test.ts`). + +--- + +### Task 1: Fail on missing inline status row in realtime append test + +**Files:** +- Modify: `tests/android-chat-incremental-realtime-append.test.ts` +- Test: `npm test tests/android-chat-incremental-realtime-append.test.ts` + +- [ ] **Step 1: Write the failing test** +```ts +assert.match( + source, + /wrapper\.addView\(BossUi\.buildMessageStatusRow\(/, + "expected every message bubble to append BossUi.buildMessageStatusRow", +); +assert.match( + source, + /List messageWarnings = collectMessageWarnings\(projectMessagesPayload, messageId\);/, + "expected the new status row builder to source grouped warnings per message", +); +assert.match( + source, + /String statusFingerprint = buildStatusFingerprint\(messageId, projectMessagesPayload\);/, + "expected realtime patches to compute a fingerprint before replacing a message view", +); +``` +- [ ] **Step 2: Run it to prove failure** +Run: `npm test tests/android-chat-incremental-realtime-append.test.ts` +Expected: FAIL because `BossUi.buildMessageStatusRow` and the new helper functions do not yet exist. +- [ ] **Step 3: Re-run after implementation to confirm success** +Run: `npm test tests/android-chat-incremental-realtime-append.test.ts` +Expected: PASS. + +### Task 2: Fail on missing helper signatures in realtime patch test + +**Files:** +- Modify: `tests/android-chat-local-realtime-patch.test.ts` +- Test: `npm test tests/android-chat-local-realtime-patch.test.ts` + +- [ ] **Step 1: Write the failing test** +```ts +assert.match( + source, + /LinearLayout statusRow = buildMessageStatusRow\(this, message, conversationTask, messageWarnings\);/, + "expected buildMessageStatusRow to be invoked when each message is rendered", +); +assert.match( + source, + /List buildMessageWarnings\(JSONObject payload, String messageId\);/, + "expected a helper that returns grouped warnings keyed by message id", +); +assert.match( + source, + /if \(!TextUtils\.equals\(currentFingerprint, nextFingerprint\)\) \{ replaceMessageViewById\(/, + "expected realtime patch to compare status fingerprint before rerendering", +); +``` +- [ ] **Step 2: Run it to prove failure** +Run: `npm test tests/android-chat-local-realtime-patch.test.ts` +Expected: FAIL because helper signatures and fingerprint comparisons are missing. +- [ ] **Step 3: Re-run after implementation to confirm success** +Run: `npm test tests/android-chat-local-realtime-patch.test.ts` +Expected: PASS. + +### Task 3: Add BossUi helper for compact status row + +**Files:** +- Modify: `android/app/src/main/java/com/hyzq/boss/BossUi.java` +- Test: `npm test tests/android-chat-incremental-realtime-append.test.ts` +- Test: `npm test tests/android-chat-local-realtime-patch.test.ts` + +- [ ] **Step 1: Implement `buildMessageStatusRow`** +Add a method that accepts `(Context context, String statusLabel, String detailText, boolean outgoing)` and returns a horizontal `LinearLayout` with spaced pills and body text, using rounded background colors and `TextView` ellipsizing. Keep it compact (max height) and re-usable for both warnings and conversation task summaries. +- [ ] **Step 2: Run targeted tests** +Run: `npm test tests/android-chat-incremental-realtime-append.test.ts tests/android-chat-local-realtime-patch.test.ts` +Expected: PASS after the helper is referenced by `ProjectDetailActivity`. + +### Task 4: Wire ProjectDetailActivity to the new status row + +**Files:** +- Modify: `android/app/src/main/java/com/hyzq/boss/ProjectDetailActivity.java` +- Test: `npm test tests/android-chat-incremental-realtime-append.test.ts` +- Test: `npm test tests/android-chat-local-realtime-patch.test.ts` + +- [ ] **Step 1: Add helper methods for grouped warnings/tasks and fingerprinting** +Implement `List collectMessageWarnings(JSONObject payload, String messageId)`, `@Nullable JSONObject findConversationTask(String messageId)`, and `private String buildStatusFingerprint(String messageId, JSONObject payload)` that concatenates task/w warning summaries so the realtime patch can detect status changes without re-rendering unchanged rows. +- [ ] **Step 2: Update `buildMessageView` to append a status row** +Inside `buildMessageView`, compute `List messageWarnings` and `JSONObject messageTask`, then call `BossUi.buildMessageStatusRow(this, description, detail, outgoing)` and add it directly beneath the bubble instead of the previous warning card logic. Use the grouped data to deduplicate identical warnings (e.g., map by `warningId` or `title+summary`). +- [ ] **Step 3: Update realtime patch logic** +In `tryPatchRealtimeExecutionWarnings`, compute `String currentFingerprint = buildStatusFingerprint(messageId, currentRenderedProjectPayload)` and `String nextFingerprint = buildStatusFingerprint(messageId, projectMessagesPayload)`; only call `replaceMessageViewById` when they differ. Ensure `currentRenderedProjectPayload` is updated once per patch and that `renderNearBottom`/`setRefreshing` behavior stays the same. +- [ ] **Step 4: Run regression tests** +Run: `npm test tests/android-chat-incremental-realtime-append.test.ts tests/android-chat-local-realtime-patch.test.ts` +Expected: PASS. + +### Task 5: Final verification + +**Files:** +- Test: all above tests + +- [ ] **Step 1: Run the two test files together** +Run: `npm test tests/android-chat-incremental-realtime-append.test.ts tests/android-chat-local-realtime-patch.test.ts` +Expected: PASS with no additional failures. diff --git a/docs/superpowers/plans/2026-04-14-hermes-backend-mvp.md b/docs/superpowers/plans/2026-04-14-hermes-backend-mvp.md new file mode 100644 index 0000000..20b4c45 --- /dev/null +++ b/docs/superpowers/plans/2026-04-14-hermes-backend-mvp.md @@ -0,0 +1,318 @@ +# Hermes Backend MVP Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 为 Boss 新增一个默认关闭、可显式启用的 `hermes-runtime`,让 `master-agent` 可通过 Hermes CLI 返回回复。 + +**Architecture:** 复用现有 `claw-runtime` 的接入形态,在 `src/lib/execution/backends/` 下新增 Hermes 配置、runner 与 backend adapter,再把 selector、主 Agent 路由与前台控制接上。第一批只开放给 `master-agent`,不碰群聊编排和 Android 独立设置页。 + +**Tech Stack:** Next.js App Router, TypeScript, Node child_process, Node test runner + +--- + +### Task 1: 补 Hermes 后端底座测试 + +**Files:** +- Create: `tests/hermes-backend-config.test.ts` +- Create: `tests/hermes-runner.test.ts` +- Create: `tests/hermes-backend.test.ts` +- Modify: `tests/execution-backend-selector.test.ts` + +- [ ] **Step 1: 写配置与 selector 的失败测试** + +```ts +test("Hermes backend 默认关闭且 selector 不会主动选中", () => { + const backends = listExecutionBackendChoices({ + primary: { provider: "master_codex_node", status: "ready" }, + backups: [{ provider: "openai_api", status: "ready" }], + requestKind: "master_agent_reply", + }); + + assert.deepEqual(backends.map((item) => item.backendId), [ + "master-codex-node", + "openai-api", + ]); +}); +``` + +- [ ] **Step 2: 写 runner 参数拼装失败测试** + +```ts +test("Hermes runner 会拼 chat -q -Q --source tool 与可选模型参数", async () => { + const result = await runHermesCommandForTesting({ + config: { + enabled: true, + command: process.execPath, + args: [scriptPath], + timeoutMs: 30_000, + defaultModel: "gpt-5.4", + sourceTag: "tool", + }, + payload: { + executionPrompt: "请输出链路正常", + model: "gpt-5.5", + }, + }); + + assert.equal(result.status, "completed"); + assert.equal(result.output, "Hermes smoke completed"); +}); +``` + +- [ ] **Step 3: 跑测试确认当前失败** + +Run: + +```bash +npx tsx --test tests/hermes-backend-config.test.ts tests/hermes-runner.test.ts tests/hermes-backend.test.ts tests/execution-backend-selector.test.ts +``` + +Expected: + +- FAIL,提示 Hermes 相关模块不存在或 selector 还不认识 `hermes-runtime` + +--- + +### Task 2: 实现 Hermes config / runner / backend + +**Files:** +- Create: `src/lib/execution/backends/hermes-config.ts` +- Create: `src/lib/execution/backends/hermes-runner.ts` +- Create: `src/lib/execution/backends/hermes-backend.ts` +- Create: `scripts/hermes-runtime-smoke.mjs` + +- [ ] **Step 1: 实现 Hermes 配置读取与可用性检查** + +实现点: + +- `BOSS_HERMES_ENABLED` +- `BOSS_HERMES_COMMAND` +- `BOSS_HERMES_ARGS` +- `BOSS_HERMES_WORKDIR` +- `BOSS_HERMES_TIMEOUT_MS` +- `BOSS_HERMES_DEFAULT_MODEL` +- `BOSS_HERMES_TOOLSETS` +- `BOSS_HERMES_SKILLS` + +- [ ] **Step 2: 实现 runner** + +固定执行形态: + +```text + chat -q -Q --source tool +``` + +可选追加: + +- `-m ` +- `-t ` +- `-s ` + +解析 quiet 模式结尾的: + +```text +session_id: xxx +``` + +但只把正文回给 Boss。 + +- [ ] **Step 3: 实现 backend adapter** + +能力保持与 Claw 一致: + +- backendId: `hermes-runtime` +- 仅支持 `master_agent_reply` / `thread_reply` +- `describe()` 返回稳定描述 + +- [ ] **Step 4: 跑测试确认转绿** + +Run: + +```bash +npx tsx --test tests/hermes-backend-config.test.ts tests/hermes-runner.test.ts tests/hermes-backend.test.ts tests/execution-backend-selector.test.ts +``` + +Expected: + +- PASS + +--- + +### Task 3: 把 Hermes 接入 selector 与主 Agent 控制面 + +**Files:** +- Modify: `src/lib/execution/backend-selector.ts` +- Modify: `src/lib/boss-data.ts` +- Modify: `src/app/api/v1/projects/[projectId]/agent-controls/route.ts` +- Modify: `src/app/api/v1/projects/[projectId]/prompt-profile/route.ts` +- Modify: `src/app/me/master-agent/page.tsx` +- Modify: `src/components/master-agent-prompt-memory-client.tsx` +- Modify: `tests/master-agent-chat-controls.test.ts` + +- [ ] **Step 1: 扩展 `backendOverride` 合法值** + +把: + +```ts +backendOverride?: "claw-runtime"; +``` + +扩为: + +```ts +backendOverride?: "claw-runtime" | "hermes-runtime"; +``` + +并同步更新 `parseBackendOverride()`。 + +- [ ] **Step 2: 扩展路由校验** + +保存 `agent-controls` / `prompt-profile` 时: + +- 允许 `hermes-runtime` +- 若 Hermes 当前不可选,直接返回 400 和原因 + +- [ ] **Step 3: 扩展 Web 控制面** + +主 Agent 提示词页的执行后端下拉新增: + +- `Hermes Runtime` + +并显示 Hermes 的可用性提示。 + +- [ ] **Step 4: 跑控制面测试** + +Run: + +```bash +npx tsx --test tests/master-agent-chat-controls.test.ts +``` + +Expected: + +- PASS,且 `backendOverride: "hermes-runtime"` 可读写 + +--- + +### Task 4: 接上 `boss-master-agent` 执行链 + +**Files:** +- Modify: `src/lib/boss-master-agent.ts` +- Modify: `tests/master-agent-message-queue.test.ts` + +- [ ] **Step 1: 写 Hermes 主 Agent 执行失败测试** + +新增与 Claw 对齐的测试: + +- 显式选择 `hermes-runtime` +- Hermes 返回结果后能写回主 Agent 会话 +- 不可用时会回退或返回明确失败 + +- [ ] **Step 2: 实现 `replyViaHermesBackend` 与 `enqueueHermesMasterAgentReply`** + +保持与 Claw 同一层级: + +- 同步模式直接执行并回写 +- enqueue 模式先排任务,再异步执行 Hermes +- Hermes 失败时按当前主链回退到 API / Master Node + +- [ ] **Step 3: 在 selector 结果里接入 Hermes 分支** + +在: + +- `replyToMasterAgentUserMessage()` + +里新增: + +- `selectedBackend.backendId === HERMES_BACKEND_ID` + +对应处理。 + +- [ ] **Step 4: 跑消息链路测试** + +Run: + +```bash +npx tsx --test tests/master-agent-message-queue.test.ts +``` + +Expected: + +- PASS,`master-agent` 显式选 Hermes 时会通过 Hermes 返回 + +--- + +### Task 5: 全量验证与文档收口 + +**Files:** +- Modify: `docs/architecture/current_runtime_and_deploy_status_cn.md` +- Modify: `docs/architecture/api_and_service_inventory_cn.md` + +- [ ] **Step 1: 更新运行时文档** + +补充: + +- `HermesBackendAdapter` 已最小接入 +- 默认关闭 +- 当前只对 `master-agent` 开放 + +- [ ] **Step 2: 运行相关测试** + +Run: + +```bash +npx tsx --test tests/hermes-backend-config.test.ts tests/hermes-runner.test.ts tests/hermes-backend.test.ts tests/execution-backend-selector.test.ts tests/master-agent-chat-controls.test.ts tests/master-agent-message-queue.test.ts +``` + +Expected: + +- PASS + +- [ ] **Step 3: 运行基础验证** + +Run: + +```bash +npm run build +npm run lint +``` + +Expected: + +- `build` PASS +- `lint` 若失败,只能是仓库已知外来大文件噪音,不能是本批新增文件 + +- [ ] **Step 4: 提交** + +```bash +git add docs/superpowers/specs/2026-04-14-hermes-backend-mvp-design.md \ + docs/superpowers/plans/2026-04-14-hermes-backend-mvp.md \ + src/lib/execution/backends/hermes-config.ts \ + src/lib/execution/backends/hermes-runner.ts \ + src/lib/execution/backends/hermes-backend.ts \ + src/lib/execution/backend-selector.ts \ + src/lib/boss-data.ts \ + src/lib/boss-master-agent.ts \ + src/app/api/v1/projects/[projectId]/agent-controls/route.ts \ + src/app/api/v1/projects/[projectId]/prompt-profile/route.ts \ + src/app/me/master-agent/page.tsx \ + src/components/master-agent-prompt-memory-client.tsx \ + scripts/hermes-runtime-smoke.mjs \ + tests/hermes-backend-config.test.ts \ + tests/hermes-runner.test.ts \ + tests/hermes-backend.test.ts \ + tests/execution-backend-selector.test.ts \ + tests/master-agent-chat-controls.test.ts \ + tests/master-agent-message-queue.test.ts \ + docs/architecture/current_runtime_and_deploy_status_cn.md \ + docs/architecture/api_and_service_inventory_cn.md +git commit -m "feat: add hermes runtime backend for master agent" +``` + +--- + +## Self-Review + +- 这份计划只覆盖 `master-agent`,没有把群聊编排、Android 独立配置页和 Hermes session 持久化一起卷进来 +- 所有代码改动都围绕现有 `claw-runtime` 接入形态做最小差异实现 +- 第一批验收点是“能选、能跑、能回退”,不是“把 Hermes 全部接满” diff --git a/docs/superpowers/specs/2026-04-14-android-chat-status-row-design.md b/docs/superpowers/specs/2026-04-14-android-chat-status-row-design.md new file mode 100644 index 0000000..fd3d90c --- /dev/null +++ b/docs/superpowers/specs/2026-04-14-android-chat-status-row-design.md @@ -0,0 +1,36 @@ +# Android chat status row spec + +## Goal +Surface `conversationTasks` and `executionWarnings` as a single compact status row under each Android chat bubble so owners can see backend work without stacking multiple cards, while keeping realtime patches minimal. + +## Motivation +- The current Android chat preview renders separate warning cards below every message and keeps an expensive list of `executionWarnings` separately, which duplicates the tap area and height of the chat list. +- The Web page already summarizes tasks and warnings inline: we should match that on Android while keeping real-time patches restrictive, only rejiggering views for the affected message. +- Requirements from the user: group warnings per message, deduplicate statuses, update only affected message on realtime patches, no web/backend changes. + +## Architecture +1. Extend `BossUi.buildMessageBubble` (or its wrapper) to accept a reusable compact status row view that can show a grouped warning count and task status. +2. When building a message view in `ProjectDetailActivity.buildMessageView`, gather all conversation task summaries and execution warnings that match the message ID. Deduplicate them by warning ID or Task ID and render them inside the status row. +3. Create helper methods to compute a `StatusRowView` (a `LinearLayout`) that displays: + - If there is an active `conversationTask`, show a row with the backend status label and session/task info. + - If there are warnings, show badge summaries with title and summary text but limit to one line (maybe ellipsized) and collapse duplicates by grouping warnings that share the same title+summary pair. +4. `buildMessageView` will add the status row view directly under the existing bubble but before any other attachments. +5. The realtime warning patch logic already replaces a specific `messageId` view; ensure it reconstructs the same minimal status row and only rerenders when the status content changes. To do that, we may need to compute a canonical string for the combined task/warnings and store/compare to avoid redundant replacements. + +## Implementation details +- Introduce new helper methods in `ProjectDetailActivity`: + - `private LinearLayout buildStatusRow(JSONObject message, List tasks, JSONArray warnings)` + - `private boolean hasStatusChangesForMessage(...)` to allow realtime patch to compare old vs new statuses without rerender unless necessary. +- `BossUi` may need a new method such as `public static LinearLayout buildMessageStatusRow(Context context, String label, String detail, int tintColor)` to standardize the UI. +- Update `ProjectDetailActivity.appendContent(buildMessageView(message))` loops to clear prior warning cards and add the new status row once. +- Keep logic around `findExecutionWarningForMessage` but adjust to return grouped data instead of each warning separately. +- Update `currentRenderedProjectPayload` handling to consider the grouped warning/task text, ensuring `hasSameExecutionWarningForMessage` returns `true` when summaries and counts match. + +## Testing approach (TDD) +1. Add a new or expand existing Android tests that read `ProjectDetailActivity.java` source to assert the presence of status row construction, grouped warnings, and realtime patch logic (e.g., `tests/android-chat-incremental-realtime-append.test.ts` and `tests/android-chat-local-realtime-patch.test.ts`). +2. Write a failing test first that expects a new helper like `buildMessageStatusRow` or `getStatusTextForMessage` to exist and to be used under each message. +3. Update tests referencing warning cards to expect the new inline status row and ensure patch logic still references the helper to rerender targeted messages only when necessary. +4. Manually run `npm test android-chat-incremental-realtime-append.test.ts` and `npm test android-chat-local-realtime-patch.test.ts` after implementing. + +## Validation +- After receiving user approval on this spec, proceed with the implementation plan using TDD. Use test file names to focus runs, and keep the Android changes confined to `ProjectDetailActivity.java`, `BossUi.java`, and the corresponding tests. diff --git a/docs/superpowers/specs/2026-04-14-hermes-backend-mvp-design.md b/docs/superpowers/specs/2026-04-14-hermes-backend-mvp-design.md new file mode 100644 index 0000000..666f6fe --- /dev/null +++ b/docs/superpowers/specs/2026-04-14-hermes-backend-mvp-design.md @@ -0,0 +1,271 @@ +# Boss `HermesBackendAdapter` 最小接入设计 + +## 1. 背景 + +Boss 现在已经有一层稳定的执行底座: + +- `ExecutionBackend` +- `ExecutionBackendSelector` +- `PromptAssembler` +- `PermissionPolicy` +- `RemoteRuntimeAdapter` + +并且已经接过两个外部项目: + +- `ClawBackendAdapter` 负责单次执行候选 +- `OmxTeamBackendAdapter` 负责编排候选 + +`hermes-agent` 的最新上游形态更像“重型 agent runner”,而不是 Boss 的产品层替代物。它的价值主要集中在: + +- 成熟的单次 agent loop +- CLI / Gateway / ACP 多入口 +- 丰富的 toolset 体系 +- 内建 skills / memory / session / search / delegation + +结论不是“把 Hermes 整体搬进 Boss”,而是把它当成新的**执行后端候选**接进 Boss 现有底座。 + +--- + +## 2. 第一批目标 + +第一批只做一件事: + +> 在不改变 Boss 当前生产主链默认行为的前提下,为 `master-agent` 新增一个默认关闭、可显式启用的 `hermes-runtime`。 + +用户能在 Boss 现有的主 Agent 对话控制里选择 `Hermes Runtime`,然后让当前 `master-agent` 的回复通过 Hermes CLI 完成。 + +这批做完后,Boss 获得的是: + +- 一个新的可选执行后端 +- 一个可继续升级的 Hermes 适配点 +- 对现有 `claw-runtime` / API / Master Codex Node 主链零破坏 + +--- + +## 3. 明确不做 + +这一批明确不做: + +- 不把 Hermes 的 gateway、Telegram、Discord、Slack、WhatsApp、Signal 接进 Boss +- 不把 Hermes 的 Honcho memory 直接并入 Boss 的 `threadStatusDocuments / threadProgressEvents` +- 不让 Boss 前台直接理解 Hermes 的 sessions、slash commands、toolsets 内部结构 +- 不接 Hermes 的多平台消息收发 +- 不接 Hermes 的 cron / ACP / editor integration +- 不改 Boss 群聊编排链路 +- 不把 Hermes 当成新的 OrchestrationBackend + +这一批的定位非常明确:**只加一个执行后端,不加一个新产品子系统。** + +--- + +## 4. 为什么 Hermes 值得接 + +### 4.1 对主 Agent 的意义 + +对 Boss 的主 Agent 来说,Hermes 的参考价值主要有三层: + +1. **成熟的一次性 agent loop** + - `hermes chat -q ... -Q` 已经提供了可脚本化的单次非交互执行入口。 + - 这正好适合 Boss 当前 `ExecutionBackend` 的“单次请求 -> 单次结果”契约。 + +2. **比 Claw 更完整的 agent runtime** + - Hermes 不只是命令封装,而是完整的 agent runner、tool registry、toolsets、skills、memory、delegation 体系。 + - 这意味着它对复杂任务的“自己调工具完成”的能力更强,适合作为 Boss 主 Agent 的增强执行候选。 + +3. **未来跨端能力的扩展面更大** + - Hermes 天然有 CLI / Gateway / ACP 三个入口。 + - 这对 Boss 未来做“同一 agent 内核,挂不同入口”很有参考价值,但第一批先不展开。 + +### 4.2 对 Boss 整体业务流程的意义 + +Hermes 对 Boss 的真正价值不在 UI,而在**执行层的替换与对照实验**: + +- 同一条 Boss 产品链 +- 同一组项目、线程、审批、导入、群聊数据 +- 不同执行后端并行存在 + +这样 Boss 可以把“产品层稳定”与“执行层可替换”真正做实。 + +--- + +## 5. 第一批接入方案 + +### 5.1 接入位置 + +Hermes 第一批接在: + +- `src/lib/execution/backends/` +- `ExecutionBackendSelector` +- `master-agent` 对话控制 + +不接: + +- `OrchestrationBackend` +- `local-agent` dispatch_execution +- Android 独立配置页 + +### 5.2 运行方式 + +Boss 不直接 import Hermes Python 代码,也不把 Hermes vendoring 进仓库。 + +Boss 通过外部命令调用 Hermes: + +```text + chat -q "" -Q --source tool +``` + +按需追加: + +- `-m ` +- `-t ` +- `-s ` + +这样做的原因: + +- 上游升级成本最低 +- Boss 与 Hermes 保持进程边界 +- 可以像 `claw-runtime` 一样通过命令 / workdir / args 做显式配置 +- 出问题时可直接回退,不污染主链 + +### 5.3 输入输出契约 + +Boss -> Hermes: + +- `executionPrompt` +- `modelOverride` +- `reasoningEffortOverride`(第一批只透传给 Boss 自身记录,不强行映射到 Hermes CLI 参数) +- 可选 `toolsets` +- 可选 `skills` + +Hermes -> Boss: + +- `stdout` 主体内容作为最终回复 +- quiet mode 末尾的 `session_id: ...` 只做解析,不写回会话正文 + +如果 Hermes 非零退出、超时、输出为空或结构不可解析,统一转成: + +- `ExecutionImmediateFailedResult` + +--- + +## 6. 配置设计 + +新增环境变量: + +- `BOSS_HERMES_ENABLED` +- `BOSS_HERMES_COMMAND` +- `BOSS_HERMES_ARGS` +- `BOSS_HERMES_WORKDIR` +- `BOSS_HERMES_TIMEOUT_MS` +- `BOSS_HERMES_DEFAULT_MODEL` +- `BOSS_HERMES_TOOLSETS` +- `BOSS_HERMES_SKILLS` + +设计约束: + +- 默认关闭 +- `enabled=true` 时若未显式设置 `BOSS_HERMES_COMMAND`,默认尝试 `hermes` +- 可执行入口不存在、工作目录不存在、前置脚本不存在时,前台不允许选择 + +--- + +## 7. Boss 内部改动点 + +### 7.1 新增文件 + +- `src/lib/execution/backends/hermes-config.ts` +- `src/lib/execution/backends/hermes-runner.ts` +- `src/lib/execution/backends/hermes-backend.ts` +- `scripts/hermes-runtime-smoke.mjs` + +### 7.2 修改文件 + +- `src/lib/execution/backend-selector.ts` +- `src/lib/boss-data.ts` +- `src/lib/boss-master-agent.ts` +- `src/app/api/v1/projects/[projectId]/agent-controls/route.ts` +- `src/app/api/v1/projects/[projectId]/prompt-profile/route.ts` +- `src/app/me/master-agent/page.tsx` +- `src/components/master-agent-prompt-memory-client.tsx` + +### 7.3 测试 + +新增或扩展: + +- `tests/hermes-backend-config.test.ts` +- `tests/hermes-runner.test.ts` +- `tests/hermes-backend.test.ts` +- `tests/execution-backend-selector.test.ts` +- `tests/master-agent-chat-controls.test.ts` +- `tests/master-agent-message-queue.test.ts` + +--- + +## 8. 关键取舍 + +### 8.1 第一批不做 session 级复用 + +Hermes quiet mode 会回 `session_id`,但 Boss 第一批不把它纳入正式状态模型。 + +原因: + +- Boss 现有 `ExecutionImmediateResult` 没有 session 归档职责 +- 当前目标是先把“后端切换可用”做通 +- 先引入 session 绑定会把 `thread/session` 关联、恢复、清理都一起带进来,范围会失控 + +第一批策略: + +- 解析但不持久化 `session_id` +- 先跑通 stateless one-shot backend + +### 8.2 第一批不把 Boss PermissionPolicy 动态映射成 Hermes toolsets + +Hermes 的 toolsets 很强,但 Boss 现在没有现成的“工具级权限 -> Hermes CLI 参数”双向映射层。 + +第一批策略: + +- 只支持静态环境变量指定 `toolsets` / `skills` +- Boss 自己仍保留顶层产品审批与后端选择权 + +这样虽然不够极致,但风险最小。 + +### 8.3 第一批只开放给 `master-agent` + +这是刻意收口,不是能力缺陷。 + +原因: + +- 当前 Boss 的后端 override UI 只在主 Agent 对话控制里成熟 +- `dispatch_execution` 牵涉群聊编排与设备执行链,接 Hermes 会把范围扩大到第二批 +- 先让主 Agent 对话用起来,收益最大、回归面最小 + +--- + +## 9. 验收标准 + +满足以下条件才算第一批完成: + +1. 未启用 `BOSS_HERMES_*` 时,Boss 行为与当前完全一致 +2. 启用且配置正确后,`master-agent` 对话控制中可选择 `Hermes Runtime` +3. 选择 `Hermes Runtime` 后,主 Agent 单次回复能通过 Hermes CLI 返回结果 +4. Hermes 不可用时,保存接口会拒绝选择,并返回明确原因 +5. 历史保存了 `hermes-runtime` 但当前不可用时,运行时会自动回退到默认后端,并给出人类可读说明 +6. `npm run build` +7. `npm run lint`(当前需排除仓库外来大文件噪音后) +8. 相关测试通过 + +--- + +## 10. 第二批预留方向 + +第一批完成后,再考虑第二批: + +- Hermes session 级复用与 Boss 项目线程关联 +- Boss 权限策略到 Hermes toolsets 的映射 +- `thread_reply` 正式挂接 +- Android 主 Agent 设置页显示 Hermes 可用性 +- `dispatch_execution` 是否引入 Hermes 作为编排前执行候选 + +第一批的成功标准不是“把 Hermes 接满”,而是: + +> 让 Boss 在现有产品层完全不变的情况下,稳定多出一个可选择、可回退、可继续升级的重型 agent 执行后端。 diff --git a/docs/superpowers/specs/2026-04-16-master-agent-fast-path-design.md b/docs/superpowers/specs/2026-04-16-master-agent-fast-path-design.md new file mode 100644 index 0000000..4cf370f --- /dev/null +++ b/docs/superpowers/specs/2026-04-16-master-agent-fast-path-design.md @@ -0,0 +1,101 @@ +# 主 Agent Fast Path 设计 + +更新时间:`2026-04-16` + +## 1. 背景 + +主 Agent 当前同时承担两类请求: + +- 需要深度理解、规划、协调的慢路径问题 +- 只需要读取本地状态、快速执行配置变更的快路径问题 + +在原实现里,只有少量模型切换命令会本地直答。大量高频问题,例如“你现在是什么大模型”“当前后端是什么”,仍会落入主推理链,导致: + +- 回复延迟明显偏高 +- 消耗不必要的 token +- 用户需要记忆更机械的命令句式 + +## 2. 设计目标 + +- 把“状态查询 / 配置操作 / 枚举类问题”从主推理链中剥离出来 +- 对高频确定性问题提供本地直答,不进入异步队列 +- 保持自然语言容错,不要求用户输入完全标准化命令 +- 为后续继续扩展快路径意图提供统一入口 + +## 3. 方案 + +### 3.1 Fast Path Router + +在 `src/lib/boss-master-agent.ts` 中新增主 Agent 快路径路由层: + +- 统一先构造 `MasterAgentFastIntentContext` +- 上下文内聚合: + - 当前登录用户作用域下的 `agentControls` + - 可见模型列表 + - 可用模型列表 + - 当前聊天意图的实际模型策略 + - 深度任务意图的实际模型策略 + - 当前 runtime 主控来源 +- `replyToMasterAgentUserMessage()` 在进入主链前先尝试 `tryHandleMasterAgentFastIntent()` + +### 3.2 第一批接入意图 + +- 模型列表查询 + - 例:“有哪些模型可以用” +- 模型切换 + - 例:“把快模型切到 gpt5.4mini” +- 当前模型状态查询 + - 例:“你现在是什么大模型” + - 例:“当前主模型是什么” + - 例:“快模型是什么” + - 例:“强模型是什么” +- 当前后端状态查询 + - 例:“当前后端是什么” + +### 3.3 自然语言归一化 + +模型名解析新增轻量归一化: + +- 忽略 `- / _ / 空格 / .` +- 兼容 `gpt5.4`、`gpt 5.4`、`gpt_5_4` +- 仍然会回落到系统当前可选模型列表做映射 + +## 4. 展示规则 + +主 Agent 快路径回复的发送者名称改成: + +- `主Agent·<当前模型名>` + +Android 主 Agent 会话页顶部标题同步显示: + +- `主Agent·<当前模型名>` + +如果当前拿不到显式模型,则退回: + +- `主Agent` + +## 5. 当前收益 + +- “查状态 / 查配置 / 改模型”这一类问题能直接秒回 +- 主 Agent 不再因为低价值查询进入慢路径 +- 用户不用死记标准句式 +- 后续扩展更多快路径意图时,只需要继续往 router 中追加 handler + +## 6. 后续扩展建议 + +下一批适合继续接入 Fast Path 的问题: + +- 当前绑定设备 / 在线设备查询 +- 当前会话 / 当前线程运行状态查询 +- GUI / CLI 默认执行模式查询 +- 当前接管开关状态查询 +- 最近活跃项目 / 最近活跃线程查询 + +## 7. 验证基线 + +本次落地至少要求以下验证通过: + +- `npx tsx --test tests/master-agent-message-queue.test.ts tests/master-agent-chat-controls.test.ts` +- `npm run build` +- `./gradlew :app:compileDebugJavaWithJavac :app:assembleDebug` +- 真机安装并验证主 Agent 名称与模型查询行为 diff --git a/local-agent/codex-task-runner.mjs b/local-agent/codex-task-runner.mjs index b9a5bc2..53a3e15 100644 --- a/local-agent/codex-task-runner.mjs +++ b/local-agent/codex-task-runner.mjs @@ -235,6 +235,10 @@ export async function prepareCodexTaskExecution(config, task, outputFile) { export function buildCodexTaskExecution(config, task, outputFile) { const { targetThreadRef, cwd } = resolveResumeTarget(config, task); const prompt = String(task?.executionPrompt || ""); + const taskExecutionModel = typeof task?.executionModel === "string" && task.executionModel.trim() + ? task.executionModel.trim() + : null; + const effectiveModel = taskExecutionModel || config.masterAgentModel; if ( targetThreadRef && @@ -247,8 +251,8 @@ export function buildCodexTaskExecution(config, task, outputFile) { "-o", outputFile, ]; - if (config.masterAgentModel) { - args.push("-m", config.masterAgentModel); + if (effectiveModel) { + args.push("-m", effectiveModel); } args.push(targetThreadRef, prompt); return { @@ -269,8 +273,8 @@ export function buildCodexTaskExecution(config, task, outputFile) { "-o", outputFile, ]; - if (config.masterAgentModel) { - args.push("-m", config.masterAgentModel); + if (effectiveModel) { + args.push("-m", effectiveModel); } args.push(prompt); return { diff --git a/scripts/hermes-runtime-smoke.mjs b/scripts/hermes-runtime-smoke.mjs new file mode 100644 index 0000000..ee9fb58 --- /dev/null +++ b/scripts/hermes-runtime-smoke.mjs @@ -0,0 +1,20 @@ +#!/usr/bin/env node + +const args = process.argv.slice(2); + +const queryIndex = args.findIndex((item) => item === "-q" || item === "--query"); +const modelIndex = args.findIndex((item) => item === "-m" || item === "--model"); +const toolsetsIndex = args.findIndex((item) => item === "-t" || item === "--toolsets"); +const skillsIndex = args.findIndex((item) => item === "-s" || item === "--skills"); +const sourceIndex = args.findIndex((item) => item === "--source"); + +const query = queryIndex >= 0 ? args[queryIndex + 1] ?? "" : ""; +const model = modelIndex >= 0 ? args[modelIndex + 1] ?? "default" : "default"; +const toolsets = toolsetsIndex >= 0 ? args[toolsetsIndex + 1] ?? "" : ""; +const skills = skillsIndex >= 0 ? args[skillsIndex + 1] ?? "" : ""; +const source = sourceIndex >= 0 ? args[sourceIndex + 1] ?? "" : ""; + +process.stdout.write( + `Hermes smoke completed: ${query} (model=${model}, toolsets=${toolsets || "default"}, skills=${skills || "default"}, source=${source || "default"})\n\n`, +); +process.stdout.write("session_id: hermes-smoke-session\n"); diff --git a/src/app/api/v1/master-agent/tasks/[taskId]/complete/route.ts b/src/app/api/v1/master-agent/tasks/[taskId]/complete/route.ts index 0322321..1adaf75 100644 --- a/src/app/api/v1/master-agent/tasks/[taskId]/complete/route.ts +++ b/src/app/api/v1/master-agent/tasks/[taskId]/complete/route.ts @@ -13,6 +13,10 @@ export async function POST( replyBody?: string; errorMessage?: string; requestId?: string; + warnings?: Array<{ + title?: string; + summary?: string; + }>; dispatchExecutionId?: string; targetProjectId?: string; targetThreadId?: string; @@ -42,6 +46,7 @@ export async function POST( replyBody: normalized.replyBody, errorMessage: normalized.errorMessage, requestId: normalized.requestId, + warnings: normalized.warnings, dispatchExecutionId: normalized.dispatchExecutionId, targetProjectId: normalized.targetProjectId, targetThreadId: normalized.targetThreadId, diff --git a/src/app/api/v1/projects/[projectId]/agent-controls/route.ts b/src/app/api/v1/projects/[projectId]/agent-controls/route.ts index 24911c3..3a73095 100644 --- a/src/app/api/v1/projects/[projectId]/agent-controls/route.ts +++ b/src/app/api/v1/projects/[projectId]/agent-controls/route.ts @@ -3,12 +3,39 @@ import { requireRequestSession } from "@/lib/boss-auth"; import { getProjectAgentControls, hasPersistedProject, + listAiAccounts, updateProjectAgentControls, } from "@/lib/boss-data"; import { getClawBackendAvailability } from "@/lib/execution/backends/claw-config"; +import { getHermesBackendAvailability } from "@/lib/execution/backends/hermes-config"; import { jsonNoStore } from "@/lib/api-response"; const reasoningEffortValues = new Set(["low", "medium", "high"]); +const MASTER_AGENT_MODEL_PRESETS = ["gpt-5.4", "gpt-5.4-mini", "gpt-4.1", "gpt-4.1-mini", "qwen3.5-plus"] as const; + +async function buildMasterAgentModelCatalog() { + const aiAccounts = await listAiAccounts(); + const availableModels = Array.from( + new Set( + aiAccounts.accounts + .filter((account) => account.canGenerate && account.model?.trim()) + .map((account) => account.model!.trim()), + ), + ); + const configuredModels = Array.from( + new Set( + aiAccounts.accounts + .map((account) => account.model?.trim()) + .filter((model): model is string => Boolean(model)), + ), + ); + + return { + availableModels, + selectableModels: Array.from(new Set([...MASTER_AGENT_MODEL_PRESETS, ...configuredModels])), + presetModels: [...MASTER_AGENT_MODEL_PRESETS], + }; +} export async function GET( request: NextRequest, @@ -25,11 +52,14 @@ export async function GET( return jsonNoStore({ ok: false, message: "UNAUTHORIZED" }, { status: 401 }); } - const [controls, clawAvailability] = await Promise.all([ + const [controls, clawAvailability, hermesAvailability] = await Promise.all([ getProjectAgentControls(projectId, session.account), getClawBackendAvailability(), + getHermesBackendAvailability(), ]); - return jsonNoStore({ ok: true, controls, clawAvailability }); + const modelCatalog = + projectId === "master-agent" ? await buildMasterAgentModelCatalog() : undefined; + return jsonNoStore({ ok: true, controls, clawAvailability, hermesAvailability, modelCatalog }); } export async function POST( @@ -56,6 +86,10 @@ export async function POST( const payload = body as { modelOverride?: unknown; reasoningEffortOverride?: unknown; + fastModelOverride?: unknown; + fastReasoningEffortOverride?: unknown; + smartModelOverride?: unknown; + smartReasoningEffortOverride?: unknown; promptOverride?: unknown; backendOverride?: unknown; takeoverEnabled?: unknown; @@ -66,6 +100,16 @@ export async function POST( payload, "reasoningEffortOverride", ); + const hasFastModelOverride = Object.prototype.hasOwnProperty.call(payload, "fastModelOverride"); + const hasFastReasoningEffortOverride = Object.prototype.hasOwnProperty.call( + payload, + "fastReasoningEffortOverride", + ); + const hasSmartModelOverride = Object.prototype.hasOwnProperty.call(payload, "smartModelOverride"); + const hasSmartReasoningEffortOverride = Object.prototype.hasOwnProperty.call( + payload, + "smartReasoningEffortOverride", + ); const hasPromptOverride = Object.prototype.hasOwnProperty.call(payload, "promptOverride"); const hasBackendOverride = Object.prototype.hasOwnProperty.call(payload, "backendOverride"); const hasTakeoverEnabled = Object.prototype.hasOwnProperty.call(payload, "takeoverEnabled"); @@ -75,16 +119,24 @@ export async function POST( ? new Set([ "modelOverride", "reasoningEffortOverride", + "fastModelOverride", + "fastReasoningEffortOverride", + "smartModelOverride", + "smartReasoningEffortOverride", "promptOverride", "backendOverride", "globalTakeoverEnabled", ]) - : new Set(["takeoverEnabled"]); + : new Set(["takeoverEnabled", "backendOverride"]); const hasUnsupportedKeys = Object.keys(payload).some((key) => !allowedKeys.has(key)); if ( ( !hasModelOverride && !hasReasoningEffortOverride && + !hasFastModelOverride && + !hasFastReasoningEffortOverride && + !hasSmartModelOverride && + !hasSmartReasoningEffortOverride && !hasPromptOverride && !hasBackendOverride && !hasTakeoverEnabled && @@ -110,6 +162,64 @@ export async function POST( { status: 400 }, ); } + if ( + hasFastModelOverride && + payload.fastModelOverride !== undefined && + payload.fastModelOverride !== null && + typeof payload.fastModelOverride !== "string" + ) { + return NextResponse.json({ ok: false, message: "INVALID_FAST_MODEL_OVERRIDE" }, { status: 400 }); + } + if ( + hasFastReasoningEffortOverride && + payload.fastReasoningEffortOverride !== undefined && + payload.fastReasoningEffortOverride !== null && + typeof payload.fastReasoningEffortOverride !== "string" + ) { + return NextResponse.json( + { ok: false, message: "INVALID_FAST_REASONING_EFFORT_OVERRIDE" }, + { status: 400 }, + ); + } + if ( + hasFastReasoningEffortOverride && + typeof payload.fastReasoningEffortOverride === "string" && + !reasoningEffortValues.has(payload.fastReasoningEffortOverride) + ) { + return NextResponse.json( + { ok: false, message: "INVALID_FAST_REASONING_EFFORT_OVERRIDE" }, + { status: 400 }, + ); + } + if ( + hasSmartModelOverride && + payload.smartModelOverride !== undefined && + payload.smartModelOverride !== null && + typeof payload.smartModelOverride !== "string" + ) { + return NextResponse.json({ ok: false, message: "INVALID_SMART_MODEL_OVERRIDE" }, { status: 400 }); + } + if ( + hasSmartReasoningEffortOverride && + payload.smartReasoningEffortOverride !== undefined && + payload.smartReasoningEffortOverride !== null && + typeof payload.smartReasoningEffortOverride !== "string" + ) { + return NextResponse.json( + { ok: false, message: "INVALID_SMART_REASONING_EFFORT_OVERRIDE" }, + { status: 400 }, + ); + } + if ( + hasSmartReasoningEffortOverride && + typeof payload.smartReasoningEffortOverride === "string" && + !reasoningEffortValues.has(payload.smartReasoningEffortOverride) + ) { + return NextResponse.json( + { ok: false, message: "INVALID_SMART_REASONING_EFFORT_OVERRIDE" }, + { status: 400 }, + ); + } if (hasPromptOverride && payload.promptOverride !== undefined && payload.promptOverride !== null && typeof payload.promptOverride !== "string") { return NextResponse.json({ ok: false, message: "INVALID_PROMPT_OVERRIDE" }, { status: 400 }); } @@ -117,7 +227,8 @@ export async function POST( hasBackendOverride && payload.backendOverride !== undefined && payload.backendOverride !== null && - payload.backendOverride !== "claw-runtime" + payload.backendOverride !== "claw-runtime" && + payload.backendOverride !== "hermes-runtime" ) { return NextResponse.json({ ok: false, message: "INVALID_BACKEND_OVERRIDE" }, { status: 400 }); } @@ -149,11 +260,29 @@ export async function POST( } } + if (hasBackendOverride && payload.backendOverride === "hermes-runtime") { + const hermesAvailability = await getHermesBackendAvailability(); + if (!hermesAvailability.selectable) { + return NextResponse.json( + { ok: false, message: hermesAvailability.reasonLabel, hermesAvailability }, + { status: 400 }, + ); + } + } + const controls = await updateProjectAgentControls( projectId, { ...(hasModelOverride ? { modelOverride: payload.modelOverride } : {}), ...(hasReasoningEffortOverride ? { reasoningEffortOverride: payload.reasoningEffortOverride } : {}), + ...(hasFastModelOverride ? { fastModelOverride: payload.fastModelOverride } : {}), + ...(hasFastReasoningEffortOverride + ? { fastReasoningEffortOverride: payload.fastReasoningEffortOverride } + : {}), + ...(hasSmartModelOverride ? { smartModelOverride: payload.smartModelOverride } : {}), + ...(hasSmartReasoningEffortOverride + ? { smartReasoningEffortOverride: payload.smartReasoningEffortOverride } + : {}), ...(hasPromptOverride ? { promptOverride: payload.promptOverride } : {}), ...(hasBackendOverride ? { backendOverride: payload.backendOverride } : {}), ...(hasTakeoverEnabled ? { takeoverEnabled: payload.takeoverEnabled } : {}), @@ -165,6 +294,7 @@ export async function POST( ok: true, controls: controls ?? null, clawAvailability: await getClawBackendAvailability(), + hermesAvailability: await getHermesBackendAvailability(), }); } catch (error) { return NextResponse.json( diff --git a/src/app/api/v1/projects/[projectId]/messages/route.ts b/src/app/api/v1/projects/[projectId]/messages/route.ts index 8469611..a8dc205 100644 --- a/src/app/api/v1/projects/[projectId]/messages/route.ts +++ b/src/app/api/v1/projects/[projectId]/messages/route.ts @@ -152,6 +152,7 @@ export async function POST( masterReplyState?: "queued" | "running" | "completed"; task?: { taskId: string; + requestMessageId: string; taskType: "conversation_reply"; status: "queued" | "running" | "completed"; }; @@ -160,6 +161,7 @@ export async function POST( let task: | { taskId: string; + requestMessageId: string; taskType: "conversation_reply"; status: "queued" | "running" | "completed"; } @@ -213,6 +215,7 @@ export async function POST( }); task = { taskId: queuedTask.taskId, + requestMessageId: queuedTask.requestMessageId, taskType: "conversation_reply", status: "queued", }; @@ -232,11 +235,11 @@ export async function POST( currentSessionExpiresAt: session.expiresAt, mode: "enqueue", }); - if (masterReply?.ok && masterReply.taskId) { - task = masterReply.task ?? null; + if (masterReply) { + if (masterReply.ok && masterReply.taskId) { + task = masterReply.task ?? null; + } masterReplyState = masterReply.masterReplyState ?? null; - } else { - masterReplyState = null; } } diff --git a/src/app/api/v1/projects/[projectId]/participants/route.ts b/src/app/api/v1/projects/[projectId]/participants/route.ts index 5bc2d1a..b140950 100644 --- a/src/app/api/v1/projects/[projectId]/participants/route.ts +++ b/src/app/api/v1/projects/[projectId]/participants/route.ts @@ -1,122 +1,28 @@ import { NextRequest, NextResponse } from "next/server"; import { requireRequestSession } from "@/lib/boss-auth"; -import { isDispatchableThreadProject, readState, replaceGroupChatMembers } from "@/lib/boss-data"; +import { readState, replaceGroupChatMembers } from "@/lib/boss-data"; +import { buildProjectParticipantsPayload } from "@/lib/boss-projections-shared"; import { jsonNoStore } from "@/lib/api-response"; -type ConversationParticipant = { - projectId: string; - deviceId: string; - threadId: string; - threadDisplayName: string; - folderName: string; - avatar: string; - isSourceProject: boolean; - status: "active" | "missing_project" | "invalid_target"; - statusLabel?: string; - canOpenProject: boolean; -}; - -function getFallbackAvatar(label: string) { - const trimmed = label.trim(); - if (!trimmed) return "A"; - return trimmed.slice(0, 1).toUpperCase(); -} - -function buildParticipant( - projectId: string, - deviceId: string, - threadId: string, - threadDisplayName: string, - folderName: string, - avatar?: string, - isSourceProject = false, - status: ConversationParticipant["status"] = "active", - canOpenProject = true, -): ConversationParticipant { - return { - projectId, - deviceId, - threadId, - threadDisplayName, - folderName, - avatar: avatar?.trim() || getFallbackAvatar(threadDisplayName), - isSourceProject, - status, - statusLabel: - status === "missing_project" - ? "引用已失效" - : status === "invalid_target" - ? "不是可下发线程" - : undefined, - canOpenProject, - }; -} - -function buildParticipantsPayload( - state: Awaited>, - projectId: string, -) { - const project = state.projects.find((item) => item.id === projectId); - if (!project) { - return null; +function mapParticipantsRepairErrorMessage(error: unknown) { + if (!(error instanceof Error)) { + return "UNKNOWN_ERROR"; } - const participants = project.isGroup - ? project.groupMembers.map((member) => { - const candidateProject = state.projects.find((item) => item.id === member.projectId); - const device = state.devices.find((item) => item.id === member.deviceId); - const status: ConversationParticipant["status"] = !candidateProject - ? "missing_project" - : isDispatchableThreadProject(candidateProject) - ? "active" - : "invalid_target"; - return buildParticipant( - member.projectId, - member.deviceId, - member.threadId, - member.threadDisplayName, - member.folderName, - device?.avatar, - member.projectId === project.id, - status, - Boolean(candidateProject), - ); - }) - : [ - buildParticipant( - project.id, - project.deviceIds[0] ?? project.id, - project.threadMeta.threadId, - project.threadMeta.threadDisplayName, - project.threadMeta.folderName, - state.devices.find((item) => item.id === project.deviceIds[0])?.avatar, - true, - ), - ]; - - const validParticipantCount = participants.filter((item) => item.status === "active").length; - const invalidParticipantCount = participants.length - validParticipantCount; - const repairRequired = - project.isGroup && (invalidParticipantCount > 0 || validParticipantCount < 2); - const repairReason = !repairRequired - ? undefined - : validParticipantCount === 0 - ? "当前群聊里还没有可下发的真实线程,请重新添加线程。" - : invalidParticipantCount > 0 - ? "当前群聊里有失效或不可下发的线程引用,请重新整理群成员。" - : "当前群聊至少需要 2 个真实线程成员。"; - - return { - ok: true, - projectId: project.id, - isGroup: project.isGroup, - threadMeta: project.threadMeta, - participants, - repairRequired, - repairReason, - validParticipantCount, - invalidParticipantCount, - }; + switch (error.message) { + case "GROUP_CHAT_MEMBER_NOT_FOUND": + return "有线程已经不存在,请刷新后重新选择。"; + case "GROUP_CHAT_MEMBER_NOT_THREAD": + return "所选项目里包含不可下发的对象,请重新选择真实线程。"; + case "GROUP_CHAT_REQUIRES_AT_LEAST_TWO_THREADS": + return "至少选择 2 个真实线程后才能修复群成员。"; + case "PROJECT_NOT_FOUND": + return "当前群聊不存在或已被删除。"; + case "PROJECT_NOT_GROUP_CHAT": + return "当前项目不是群聊,无法修复群成员。"; + default: + return error.message; + } } export async function GET( @@ -130,7 +36,7 @@ export async function GET( const { projectId } = await context.params; const state = await readState(); - const payload = buildParticipantsPayload(state, projectId); + const payload = buildProjectParticipantsPayload(state, projectId); if (!payload) { return jsonNoStore({ ok: false, message: "PROJECT_NOT_FOUND" }, { status: 404 }); } @@ -162,14 +68,14 @@ export async function POST( requestedBy: session.account, }); const nextState = await readState(); - const payload = buildParticipantsPayload(nextState, projectId); + const payload = buildProjectParticipantsPayload(nextState, projectId); if (!payload) { return NextResponse.json({ ok: false, message: "PROJECT_NOT_FOUND" }, { status: 404 }); } return NextResponse.json(payload); } catch (error) { return NextResponse.json( - { ok: false, message: error instanceof Error ? error.message : "UNKNOWN_ERROR" }, + { ok: false, message: mapParticipantsRepairErrorMessage(error) }, { status: 400 }, ); } diff --git a/src/app/api/v1/projects/[projectId]/prompt-profile/route.ts b/src/app/api/v1/projects/[projectId]/prompt-profile/route.ts index 7ce1225..9c76929 100644 --- a/src/app/api/v1/projects/[projectId]/prompt-profile/route.ts +++ b/src/app/api/v1/projects/[projectId]/prompt-profile/route.ts @@ -10,6 +10,7 @@ import { updateUserMasterPrompt, } from "@/lib/boss-data"; import { getClawBackendAvailability } from "@/lib/execution/backends/claw-config"; +import { getHermesBackendAvailability } from "@/lib/execution/backends/hermes-config"; import { jsonNoStore } from "@/lib/api-response"; export async function GET( @@ -27,11 +28,12 @@ export async function GET( return jsonNoStore({ ok: false, message: "PROJECT_NOT_FOUND" }, { status: 404 }); } - const [promptPolicy, userPrompt, projectControls, clawAvailability] = await Promise.all([ + const [promptPolicy, userPrompt, projectControls, clawAvailability, hermesAvailability] = await Promise.all([ getMasterAgentPromptPolicy(), getUserMasterPrompt(session.account), getProjectAgentControls(projectId, session.account), getClawBackendAvailability(), + getHermesBackendAvailability(), ]); return jsonNoStore({ @@ -42,6 +44,7 @@ export async function GET( projectControls, projectPromptOverride: projectControls?.promptOverride ?? null, clawAvailability, + hermesAvailability, account: session.account, }); } @@ -104,6 +107,7 @@ export async function POST( && typeof payload.backendOverride === "string" && payload.backendOverride.trim() !== "" && payload.backendOverride.trim() !== "claw-runtime" + && payload.backendOverride.trim() !== "hermes-runtime" ) { return NextResponse.json({ ok: false, message: "INVALID_BACKEND_OVERRIDE" }, { status: 400 }); } @@ -127,6 +131,24 @@ export async function POST( } } + if ( + hasBackendOverride && + typeof payload.backendOverride === "string" && + payload.backendOverride.trim() === "hermes-runtime" + ) { + const hermesAvailability = await getHermesBackendAvailability(); + if (!hermesAvailability.selectable) { + return NextResponse.json( + { + ok: false, + message: hermesAvailability.reasonLabel, + hermesAvailability, + }, + { status: 400 }, + ); + } + } + if (hasUserPromptContent) { const userPromptContent = typeof payload.userPromptContent === "string" ? payload.userPromptContent.trim() : ""; if (userPromptContent) { @@ -143,11 +165,12 @@ export async function POST( }, session.account); } - const [promptPolicy, userPrompt, projectControls, clawAvailability] = await Promise.all([ + const [promptPolicy, userPrompt, projectControls, clawAvailability, hermesAvailability] = await Promise.all([ getMasterAgentPromptPolicy(), getUserMasterPrompt(session.account), getProjectAgentControls(projectId, session.account), getClawBackendAvailability(), + getHermesBackendAvailability(), ]); return NextResponse.json({ @@ -158,6 +181,7 @@ export async function POST( projectControls, projectPromptOverride: projectControls?.promptOverride ?? null, clawAvailability, + hermesAvailability, account: session.account, }); } catch (error) { diff --git a/src/app/conversations/[projectId]/page.tsx b/src/app/conversations/[projectId]/page.tsx index 2206075..905c7c4 100644 --- a/src/app/conversations/[projectId]/page.tsx +++ b/src/app/conversations/[projectId]/page.tsx @@ -15,14 +15,58 @@ import { import { requirePageSession } from "@/lib/boss-auth"; import { getProjectOrchestrationBackendState, - listDispatchPlansByProject, readState, } from "@/lib/boss-data"; import { resolveDispatchPlanComposerState } from "@/lib/dispatch-plan-ui"; -import { formatTimestampLabel, getProjectDetailView } from "@/lib/boss-projections"; +import { getProjectDetailView } from "@/lib/boss-projections"; +import { formatTimestampLabel } from "@/lib/boss-projections-shared"; export const dynamic = "force-dynamic"; +function conversationTaskStatusLabel(status: string) { + switch (status) { + case "queued": + return "排队中"; + case "running": + return "执行中"; + case "completed": + return "已完成"; + case "failed": + return "已失败"; + default: + return status; + } +} + +function dedupeExecutionWarnings< + T extends { + title: string; + summary: string; + taskId: string; + requestMessageId: string; + sessionId?: string; + requestId?: string; + createdAt: string; + }, +>(warnings: T[]) { + const warningMap = new Map(); + for (const warning of warnings) { + const dedupeKey = [ + warning.requestMessageId, + warning.taskId, + warning.sessionId ?? "", + warning.requestId ?? "", + warning.title, + warning.summary, + ].join("::"); + const existing = warningMap.get(dedupeKey); + if (!existing || existing.createdAt < warning.createdAt) { + warningMap.set(dedupeKey, warning); + } + } + return Array.from(warningMap.values()).sort((left, right) => right.createdAt.localeCompare(left.createdAt)); +} + export default async function ProjectChatPage({ params, }: { @@ -32,15 +76,16 @@ export default async function ProjectChatPage({ const { projectId } = await params; const state = await readState(); const detail = getProjectDetailView(state, projectId, session.account); - const dispatchPlanState = detail?.project.isGroup - ? resolveDispatchPlanComposerState(await listDispatchPlansByProject(projectId)) - : resolveDispatchPlanComposerState([]); - const orchestrationBackendState = detail?.project.isGroup - ? await getProjectOrchestrationBackendState(projectId) - : null; if (!detail) notFound(); + const dispatchPlanState = detail.project.isGroup + ? resolveDispatchPlanComposerState(detail.dispatchPlans) + : resolveDispatchPlanComposerState([]); + const orchestrationBackendState = detail.project.isGroup + ? await getProjectOrchestrationBackendState(projectId) + : null; + return ( ) : null}
- +
{detail.project.isGroup && orchestrationBackendState ? (
@@ -99,6 +144,37 @@ export default async function ProjectChatPage({ />
) : null} + {detail.project.isGroup && detail.participantsPayload && detail.participantsPayload.repairRequired ? ( +
+
修复群成员
+
+ {detail.participantsPayload.repairReason || "当前群聊里有失效线程,请先修复群成员。"} +
+
+ 有效线程 {detail.participantsPayload.validParticipantCount} 个 · 异常成员 {detail.participantsPayload.invalidParticipantCount} 个 +
+
+ {detail.participantsPayload.participants.filter((participant) => participant.status !== "active").map((participant) => ( +
+
{participant.threadDisplayName}
+
+ {participant.statusLabel ?? participant.status} + {participant.canOpenProject ? " · 可打开项目" : " · 项目引用已丢失"} +
+
+ ))} +
+ + 去修复群成员 + +
+ ) : null}
主 Agent 调度结论
@@ -172,9 +248,74 @@ export default async function ProjectChatPage({
- {detail.project.messages.map((message) => ( - - ))} + {detail.project.messages.map((message) => { + const messageTask = detail.conversationTasks.find((task) => task.requestMessageId === message.id); + const warningMap = new Map(); + for (const warning of detail.executionWarnings) { + if (warning.requestMessageId !== message.id) continue; + const dedupeKey = [ + warning.requestMessageId, + warning.taskId, + warning.sessionId ?? "", + warning.requestId ?? "", + warning.title, + warning.summary, + ].join("::"); + const existing = warningMap.get(dedupeKey); + if (!existing || existing.createdAt < warning.createdAt) { + warningMap.set(dedupeKey, warning); + } + } + const dedupedWarnings = dedupeExecutionWarnings(Array.from(warningMap.values())); + return ( +
+ + {messageTask ? ( +
+
+ 线程状态 · {conversationTaskStatusLabel(messageTask.status)} +
+
+ {messageTask.sessionId ? `Session ${messageTask.sessionId}` : `Task ${messageTask.taskId}`} + {messageTask.targetThreadId ? ` · 线程 ${messageTask.targetThreadId}` : ""} +
+
+ ) : null} + {dedupedWarnings.map((warning) => ( + warning.requestMessageId === message.id ? ( +
+
{warning.title}
+
{warning.summary}
+
+ {warning.sessionId ? `Session ${warning.sessionId}` : `Task ${warning.taskId}`} ·{" "} + {formatTimestampLabel(warning.createdAt)} +
+
+ ) : null + ))} + {dedupedWarnings.length > 1 ? ( +
+ 当前这条消息共有 {dedupedWarnings.length} 条远端提醒,已去重并按时间顺序展开。 +
+ ) : null} +
+ ); + })} + {detail.conversationTasks.length ? ( +
+
线程执行状态
+
+ {detail.conversationTasks.length} 个后台回复任务,最近状态: + {conversationTaskStatusLabel(detail.conversationTasks[0].status)} + {detail.conversationTasks[0].sessionId ? ` · Session ${detail.conversationTasks[0].sessionId}` : ""} +
+
+ ) : null}
语音、图片、视频和转发入口已经接到当前消息账本。对象存储和真实媒体文件仍保持 MVP 占位。
diff --git a/src/app/conversations/[projectId]/participants/page.tsx b/src/app/conversations/[projectId]/participants/page.tsx new file mode 100644 index 0000000..2552f5b --- /dev/null +++ b/src/app/conversations/[projectId]/participants/page.tsx @@ -0,0 +1,150 @@ +import Link from "next/link"; +import { notFound } from "next/navigation"; +import { RealtimeRefresh } from "@/components/app-runtime"; +import { AppShell, PageNav, StatusBar } from "@/components/app-ui"; +import { GroupParticipantsRepairClient } from "@/components/group-participants-repair-client"; +import { requirePageSession } from "@/lib/boss-auth"; +import { isDispatchableThreadProject, readState } from "@/lib/boss-data"; +import { getProjectDetailView } from "@/lib/boss-projections"; + +export const dynamic = "force-dynamic"; + +export default async function ParticipantsPage({ + params, + searchParams, +}: { + params: Promise<{ projectId: string }>; + searchParams: Promise<{ repaired?: string | string[] | undefined }>; +}) { + const session = await requirePageSession(); + const { projectId } = await params; + const repaired = (await searchParams).repaired; + const state = await readState(); + const detail = getProjectDetailView(state, projectId, session.account); + if (!detail) notFound(); + + const participantsPayload = detail.participantsPayload; + const participants = participantsPayload?.participants ?? []; + const invalidParticipants = participants.filter((participant) => participant.status !== "active"); + const availableThreads = state.projects + .filter((project) => project.id !== projectId && isDispatchableThreadProject(project)) + .map((project) => ({ + projectId: project.id, + threadDisplayName: project.threadMeta.threadDisplayName, + folderName: project.threadMeta.folderName, + })); + const canRepairGroupMembers = availableThreads.length >= 2; + const initialSelectedProjectIds = participants + .filter((participant) => participant.status === "active") + .map((participant) => participant.projectId); + + return ( + + + + +
+ {repaired === "1" ? ( +
+
群成员已更新
+
当前群聊已经切换到新的真实线程成员。
+
+ ) : null} + +
+
{detail.project.name}
+
+ {participantsPayload?.threadMeta?.folderName?.trim() || "群聊"} · 成员状态 +
+ {participantsPayload ? ( +
+ 有效线程 {participantsPayload.validParticipantCount} 个 · 异常成员 {participantsPayload.invalidParticipantCount} 个 +
+ ) : ( +
当前项目没有成员状态数据。
+ )} +
+ + {participantsPayload?.repairRequired ? ( +
+
修复群成员
+
+ {participantsPayload.repairReason || "当前群聊里有失效线程,请先修复群成员。"} +
+
+ ) : null} + + {participantsPayload?.repairRequired && canRepairGroupMembers ? ( + + ) : null} + + {participantsPayload?.repairRequired && !canRepairGroupMembers ? ( +
+
当前暂时无法直接修复
+
当前设备里暂时没有足够的真实线程可用于修复群成员。
+
+ ) : null} + + {invalidParticipants.length ? ( +
+
异常成员
+
+ {invalidParticipants.map((participant) => ( +
+
{participant.threadDisplayName}
+
{participant.statusLabel ?? participant.status}
+
+ {participant.folderName} + {participant.canOpenProject ? " · 可打开项目" : " · 项目引用已丢失"} +
+ {participant.canOpenProject ? ( +
+ + 打开对应线程 + +
+ ) : null} +
+ ))} +
+
+ ) : null} + +
+
全部成员
+
+ {participants.length ? ( + participants.map((participant) => ( +
+
{participant.threadDisplayName}
+
{participant.folderName}
+
+ {participant.statusLabel ?? participant.status} + {participant.isSourceProject ? " · 当前群聊来源线程" : ""} +
+
+ )) + ) : ( +
+ 当前还没有成员状态数据。 +
+ )} +
+
+
+
+ ); +} diff --git a/src/app/conversations/[projectId]/thread-status/page.tsx b/src/app/conversations/[projectId]/thread-status/page.tsx index b28e9d5..8746a4b 100644 --- a/src/app/conversations/[projectId]/thread-status/page.tsx +++ b/src/app/conversations/[projectId]/thread-status/page.tsx @@ -4,7 +4,7 @@ import { AppShell, PageNav, StatusBar } from "@/components/app-ui"; import { requirePageSession } from "@/lib/boss-auth"; import { readState } from "@/lib/boss-data"; import { getProjectDetailView } from "@/lib/boss-projections"; -import { formatTimestampLabel } from "@/lib/boss-projections"; +import { formatTimestampLabel } from "@/lib/boss-projections-shared"; export const dynamic = "force-dynamic"; diff --git a/src/app/me/master-agent/page.tsx b/src/app/me/master-agent/page.tsx index 31054e3..fab7245 100644 --- a/src/app/me/master-agent/page.tsx +++ b/src/app/me/master-agent/page.tsx @@ -4,6 +4,7 @@ import { MasterAgentPromptMemoryClient } from "@/components/master-agent-prompt- import { requirePageSession } from "@/lib/boss-auth"; import { MASTER_AGENT_CHAT_PAGE_ANCHORS } from "@/lib/master-agent-chat-menu"; import { getClawBackendAvailability } from "@/lib/execution/backends/claw-config"; +import { getHermesBackendAvailability } from "@/lib/execution/backends/hermes-config"; import { getMasterAgentPromptPolicy, getProjectAgentControls, @@ -15,7 +16,7 @@ export const dynamic = "force-dynamic"; export default async function MasterAgentPromptMemoryPage() { const session = await requirePageSession(); - const [promptPolicy, userPrompt, projectControls, globalMemories, projectMemories, clawAvailability] = + const [promptPolicy, userPrompt, projectControls, globalMemories, projectMemories, clawAvailability, hermesAvailability] = await Promise.all([ getMasterAgentPromptPolicy(), getUserMasterPrompt(session.account), @@ -23,6 +24,7 @@ export default async function MasterAgentPromptMemoryPage() { listUserMasterMemories(session.account, { includeArchived: false, scope: "global" }), listUserMasterMemories(session.account, { includeArchived: false, scope: "project" }), getClawBackendAvailability(), + getHermesBackendAvailability(), ]); return ( @@ -47,6 +49,7 @@ export default async function MasterAgentPromptMemoryPage() { userPrompt={userPrompt} projectControls={projectControls} clawAvailability={clawAvailability} + hermesAvailability={hermesAvailability} globalMemories={globalMemories} projectMemories={projectMemories} anchors={MASTER_AGENT_CHAT_PAGE_ANCHORS} diff --git a/src/app/me/master-agent/takeover/page.tsx b/src/app/me/master-agent/takeover/page.tsx index afeefa7..9db20d0 100644 --- a/src/app/me/master-agent/takeover/page.tsx +++ b/src/app/me/master-agent/takeover/page.tsx @@ -3,7 +3,7 @@ import { AppShell, PageNav, StatusBar } from "@/components/app-ui"; import { MasterAgentTakeoverClient } from "@/components/master-agent-takeover-client"; import { requirePageSession } from "@/lib/boss-auth"; import { getProjectAgentControls } from "@/lib/boss-data"; -import { formatTimestampLabel } from "@/lib/boss-projections"; +import { formatTimestampLabel } from "@/lib/boss-projections-shared"; export const dynamic = "force-dynamic"; diff --git a/src/components/ai-accounts-client.tsx b/src/components/ai-accounts-client.tsx index a3148c7..b402965 100644 --- a/src/components/ai-accounts-client.tsx +++ b/src/components/ai-accounts-client.tsx @@ -16,7 +16,7 @@ import { resolveAliyunQwenModelSelection, resolveAliyunQwenModelValue, } from "@/lib/ai-account-models"; -import { formatTimestampLabel } from "@/lib/boss-projections"; +import { formatTimestampLabel } from "@/lib/boss-projections-shared"; type AccountDraft = { label: string; diff --git a/src/components/app-runtime.tsx b/src/components/app-runtime.tsx index e7b502a..ccf91e0 100644 --- a/src/components/app-runtime.tsx +++ b/src/components/app-runtime.tsx @@ -11,7 +11,7 @@ import { planThrottledRefresh, shouldRefreshRealtimeEvent, } from "@/lib/realtime-refresh"; -import type { SkillInventoryDeviceGroup } from "@/lib/boss-projections"; +import type { SkillInventoryDeviceGroup } from "@/lib/boss-projections-shared"; import { clearNativeSessionSnapshot, currentAppLocation, diff --git a/src/components/app-ui.tsx b/src/components/app-ui.tsx index 76b10d4..1ddce24 100644 --- a/src/components/app-ui.tsx +++ b/src/components/app-ui.tsx @@ -48,8 +48,8 @@ import type { UserProfile, UserSettings, } from "@/lib/boss-data"; -import type { ConversationItem, DeviceWorkspaceView } from "@/lib/boss-projections"; -import { formatTimestampLabel } from "@/lib/boss-projections"; +import type { ConversationItem, DeviceWorkspaceView } from "@/lib/boss-projections-shared"; +import { formatTimestampLabel } from "@/lib/boss-projections-shared"; function formatClock(value: string) { return formatTimestampLabel(value); @@ -914,7 +914,7 @@ export function ChatBubble({ message }: { message: Message }) { ); } -export function ProjectHeaderActions({ projectId }: { projectId: string }) { +export function ProjectHeaderActions({ projectId, isGroup = false }: { projectId: string; isGroup?: boolean }) { return (
- 线程状态 + {isGroup ? "成员状态" : "线程状态"}
); diff --git a/src/components/group-participants-repair-client.tsx b/src/components/group-participants-repair-client.tsx new file mode 100644 index 0000000..49a1c46 --- /dev/null +++ b/src/components/group-participants-repair-client.tsx @@ -0,0 +1,115 @@ +"use client"; + +import { useRouter } from "next/navigation"; +import { useState } from "react"; + +export interface GroupParticipantsRepairTarget { + projectId: string; + threadDisplayName: string; + folderName: string; +} + +export function GroupParticipantsRepairClient({ + projectId, + availableThreads, + initialSelectedProjectIds, +}: { + projectId: string; + availableThreads: GroupParticipantsRepairTarget[]; + initialSelectedProjectIds: string[]; +}) { + const router = useRouter(); + const [selectedProjectIds, setSelectedProjectIds] = useState(() => new Set(initialSelectedProjectIds)); + const [message, setMessage] = useState(""); + const [loading, setLoading] = useState(false); + const canSubmit = !loading && availableThreads.length >= 2 && selectedProjectIds.size >= 2; + + function toggleProject(projectId: string) { + setSelectedProjectIds((current) => { + const next = new Set(current); + if (next.has(projectId)) { + next.delete(projectId); + } else { + next.add(projectId); + } + return next; + }); + } + + async function submitRepair() { + if (availableThreads.length < 2) { + setMessage("当前没有足够的真实线程可用于修复群成员。"); + return; + } + const memberProjectIds = Array.from(selectedProjectIds); + if (memberProjectIds.length < 2) { + setMessage("至少选择 2 个真实线程后才能修复群成员。"); + return; + } + + setLoading(true); + setMessage(""); + try { + const response = await fetch(`/api/v1/projects/${projectId}/participants`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ memberProjectIds }), + }); + const result = (await response.json().catch(() => ({}))) as { ok?: boolean; message?: string }; + if (!response.ok || !result.ok) { + setMessage(result.message ?? "修复群成员失败,请稍后重试。"); + return; + } + setMessage("已更新群成员。"); + router.replace(`/conversations/${projectId}/participants?repaired=1`); + router.refresh(); + } catch (error) { + setMessage(error instanceof Error ? error.message : "网络异常,修复群成员失败。"); + } finally { + setLoading(false); + } + } + + return ( +
+
选择真实线程修复群成员
+
+ 勾选至少 2 个真实线程,提交后会替换当前失效成员。 +
+
+ {availableThreads.length ? ( + availableThreads.map((thread) => ( + + )) + ) : ( +
+ 当前设备里暂时没有足够的真实线程可用于修复群成员。 +
+ )} +
+ + {message ?
{message}
: null} +
+ ); +} diff --git a/src/components/master-agent-prompt-memory-client.tsx b/src/components/master-agent-prompt-memory-client.tsx index 8ee1187..b02ec37 100644 --- a/src/components/master-agent-prompt-memory-client.tsx +++ b/src/components/master-agent-prompt-memory-client.tsx @@ -12,7 +12,7 @@ import type { UserMasterPrompt, } from "@/lib/boss-data"; import type { MasterAgentChatPageAnchors } from "@/lib/master-agent-chat-menu"; -import { formatTimestampLabel } from "@/lib/boss-projections"; +import { formatTimestampLabel } from "@/lib/boss-projections-shared"; type MemoryDraft = { scope: MasterMemoryScope; @@ -31,6 +31,13 @@ type ClawAvailability = { reasonLabel: string; }; +type HermesAvailability = { + status: "disabled" | "misconfigured" | "ready"; + selectable: boolean; + reason: string; + reasonLabel: string; +}; + const memoryScopeOptions: Array<{ value: MasterMemoryScope; label: string }> = [ { value: "global", label: "通用记忆" }, { value: "project", label: "项目记忆" }, @@ -153,6 +160,7 @@ export function MasterAgentPromptMemoryClient({ userPrompt, projectControls, clawAvailability, + hermesAvailability, globalMemories, projectMemories, anchors, @@ -162,6 +170,7 @@ export function MasterAgentPromptMemoryClient({ userPrompt: UserMasterPrompt | null; projectControls: ProjectAgentControls | null; clawAvailability: ClawAvailability; + hermesAvailability: HermesAvailability; globalMemories: MasterAgentMemory[]; projectMemories: MasterAgentMemory[]; anchors: MasterAgentChatPageAnchors; @@ -175,11 +184,25 @@ export function MasterAgentPromptMemoryClient({ const [reasoningEffortOverride, setReasoningEffortOverride] = useState( projectControls?.reasoningEffortOverride ?? "", ); + const [fastModelOverride, setFastModelOverride] = useState(projectControls?.fastModelOverride ?? ""); + const [fastReasoningEffortOverride, setFastReasoningEffortOverride] = useState( + projectControls?.fastReasoningEffortOverride ?? "", + ); + const [smartModelOverride, setSmartModelOverride] = useState(projectControls?.smartModelOverride ?? ""); + const [smartReasoningEffortOverride, setSmartReasoningEffortOverride] = useState( + projectControls?.smartReasoningEffortOverride ?? "", + ); const [promptOverride, setPromptOverride] = useState(projectControls?.promptOverride ?? ""); const storedClawOverrideUnavailable = projectControls?.backendOverride === "claw-runtime" && !clawAvailability.selectable; + const storedHermesOverrideUnavailable = + projectControls?.backendOverride === "hermes-runtime" && !hermesAvailability.selectable; const [backendOverride, setBackendOverride] = useState( - projectControls?.backendOverride === "claw-runtime" && clawAvailability.selectable ? "claw-runtime" : "", + projectControls?.backendOverride === "claw-runtime" && clawAvailability.selectable + ? "claw-runtime" + : projectControls?.backendOverride === "hermes-runtime" && hermesAvailability.selectable + ? "hermes-runtime" + : "", ); const [newMemory, setNewMemory] = useState(makeNewMemoryDraft()); const [memoryDrafts, setMemoryDrafts] = useState>(() => { @@ -200,10 +223,19 @@ export function MasterAgentPromptMemoryClient({ ? `【执行后端】\n${backendOverride.trim()}` : storedClawOverrideUnavailable ? "【执行后端】\n默认(Claw Runtime 当前不可用,运行时会自动回退)" + : storedHermesOverrideUnavailable + ? "【执行后端】\n默认(Hermes Runtime 当前不可用,运行时会自动回退)" : null, ].filter(Boolean); return sections.length > 0 ? sections.join("\n\n") : "当前还没有组合后的提示词内容。"; - }, [backendOverride, globalPrompt, promptOverride, storedClawOverrideUnavailable, userPromptContent]); + }, [ + backendOverride, + globalPrompt, + promptOverride, + storedClawOverrideUnavailable, + storedHermesOverrideUnavailable, + userPromptContent, + ]); function updateMemoryDraft(memoryId: string, updater: (draft: MemoryDraft) => MemoryDraft) { setMemoryDrafts((current) => ({ @@ -264,6 +296,10 @@ export function MasterAgentPromptMemoryClient({ body: JSON.stringify({ modelOverride: modelOverride.trim() || null, reasoningEffortOverride: reasoningEffortOverride.trim() || null, + fastModelOverride: fastModelOverride.trim() || null, + fastReasoningEffortOverride: fastReasoningEffortOverride.trim() || null, + smartModelOverride: smartModelOverride.trim() || null, + smartReasoningEffortOverride: smartReasoningEffortOverride.trim() || null, promptOverride: promptOverride.trim() || null, backendOverride: backendOverride.trim() || null, }), @@ -432,6 +468,7 @@ export function MasterAgentPromptMemoryClient({ > + @@ -458,6 +495,63 @@ export function MasterAgentPromptMemoryClient({ > {clawAvailability.selectable ? : null} + {hermesAvailability.selectable ? : null} + + +
+
+ + + +
@@ -472,6 +566,17 @@ export function MasterAgentPromptMemoryClient({ ) : null} ) : null} + {!hermesAvailability.selectable ? ( +
+
Hermes Runtime 当前不可用
+
{hermesAvailability.reasonLabel}
+ {storedHermesOverrideUnavailable ? ( +
+ 当前对话之前保存过 Hermes Runtime,运行时会自动回退到默认后端。 +
+ ) : null} +
+ ) : null}