From 5ebb37cbfc35b21a5932fd7b5258d186c581d236 Mon Sep 17 00:00:00 2001 From: kris Date: Sat, 4 Apr 2026 03:00:34 +0800 Subject: [PATCH] feat: streamline group dispatch reminders --- .../java/com/hyzq/boss/BossApiClient.java | 18 +++- .../java/com/hyzq/boss/GroupInfoActivity.java | 46 +++++++++ .../com/hyzq/boss/ProjectChatUiState.java | 44 +++++++++ .../com/hyzq/boss/ProjectDetailActivity.java | 63 +++++++------ .../com/hyzq/boss/GroupInfoActivityTest.java | 55 +++++++++++ .../boss/ProjectDetailActivityUiTest.java | 37 +++++++- .../dispatch-plans/[planId]/confirm/route.ts | 2 + .../[projectId]/dispatch-reminder/route.ts | 48 ++++++++++ src/app/conversations/[projectId]/page.tsx | 1 + src/components/app-ui.tsx | 53 +++++++++-- src/lib/boss-data.ts | 39 +++++++- src/lib/dispatch-plan-ui.ts | 23 +++++ tests/dispatch-plan-confirmation.test.ts | 93 ++++++++++++++++++- 13 files changed, 485 insertions(+), 37 deletions(-) create mode 100644 src/app/api/v1/projects/[projectId]/dispatch-reminder/route.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 34da070..6ad2e5d 100644 --- a/android/app/src/main/java/com/hyzq/boss/BossApiClient.java +++ b/android/app/src/main/java/com/hyzq/boss/BossApiClient.java @@ -167,12 +167,18 @@ public class BossApiClient { return requestWithRestore("DELETE", "/api/v1/projects/" + encode(projectId) + "/memories/" + encode(memoryId), null); } - public ApiResponse confirmDispatchPlan(String projectId, String planId, JSONArray approvedTargetProjectIds) throws IOException, JSONException { + public ApiResponse confirmDispatchPlan( + String projectId, + String planId, + JSONArray approvedTargetProjectIds, + boolean rememberLightReminder + ) throws IOException, JSONException { JSONObject payload = new JSONObject(); payload.put( "approvedTargetProjectIds", approvedTargetProjectIds == null ? new JSONArray() : approvedTargetProjectIds ); + payload.put("rememberLightReminder", rememberLightReminder); return requestWithRestoreRaw( "POST", "/api/v1/projects/" + encode(projectId) + "/dispatch-plans/" + encode(planId) + "/confirm", @@ -182,6 +188,10 @@ public class BossApiClient { ); } + public ApiResponse confirmDispatchPlan(String projectId, String planId, JSONArray approvedTargetProjectIds) throws IOException, JSONException { + return confirmDispatchPlan(projectId, planId, approvedTargetProjectIds, false); + } + public ApiResponse rejectDispatchPlan(String projectId, String planId) throws IOException, JSONException { return requestWithRestoreRaw( "POST", @@ -227,6 +237,12 @@ public class BossApiClient { return requestWithRestore("POST", "/api/v1/projects/" + encode(projectId) + "/participants", payload); } + public ApiResponse updateProjectDispatchReminder(String projectId, boolean lightDispatchReminderEnabled) throws IOException, JSONException { + JSONObject payload = new JSONObject(); + payload.put("lightDispatchReminderEnabled", lightDispatchReminderEnabled); + return requestWithRestore("PATCH", "/api/v1/projects/" + encode(projectId) + "/dispatch-reminder", payload); + } + public ApiResponse sendProjectMessage(String projectId, String body, String kind) throws IOException, JSONException { JSONObject payload = new JSONObject(); payload.put("body", body); 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 a2c62ba..eac025b 100644 --- a/android/app/src/main/java/com/hyzq/boss/GroupInfoActivity.java +++ b/android/app/src/main/java/com/hyzq/boss/GroupInfoActivity.java @@ -106,6 +106,7 @@ public class GroupInfoActivity extends BossScreenActivity { if (orchestrationBackendPayload != null) { appendContent(buildOrchestrationBackendRow(orchestrationBackendPayload)); } + appendContent(buildDispatchReminderRow(project)); if (repairRequired) { String meta = invalidParticipantCount > 0 @@ -150,6 +151,18 @@ public class GroupInfoActivity extends BossScreenActivity { setRefreshing(false); } + private LinearLayout buildDispatchReminderRow(JSONObject project) { + boolean enabled = project.optBoolean("lightDispatchReminderEnabled", false); + return BossUi.buildWechatMenuRow( + this, + "推荐下发默认轻提醒", + enabled ? "已开启" : "已关闭", + enabled ? "后续推荐会保留轻状态卡,不再弹重确认提醒。" : "当前仍会显式提醒你确认主 Agent 推荐。", + enabled ? "开启" : "关闭", + v -> openDispatchReminderDialog(enabled) + ); + } + private LinearLayout buildMemberRow(JSONObject participant) { boolean sourceProject = participant.optBoolean("isSourceProject", false); boolean canOpenProject = participant.optBoolean("canOpenProject", true); @@ -426,6 +439,39 @@ public class GroupInfoActivity extends BossScreenActivity { }); } + private void openDispatchReminderDialog(boolean enabled) { + CharSequence[] items = enabled + ? new CharSequence[]{"关闭默认轻提醒"} + : new CharSequence[]{"开启默认轻提醒"}; + new AlertDialog.Builder(this) + .setTitle("推荐下发默认轻提醒") + .setMessage(enabled + ? "关闭后,这个群会恢复成每次都显式确认的提醒方式。" + : "开启后,这个群后续仍会显示一张轻状态卡,但不再出现重提醒。") + .setItems(items, (dialog, which) -> saveDispatchReminderPreference(!enabled)) + .setNegativeButton("取消", null) + .show(); + } + + private void saveDispatchReminderPreference(boolean enabled) { + setRefreshing(true); + executor.execute(() -> { + try { + BossApiClient.ApiResponse response = apiClient.updateProjectDispatchReminder(projectId, enabled); + if (!response.ok()) throw new IllegalStateException(response.message()); + runOnUiThread(() -> { + showMessage(enabled ? "已开启默认轻提醒" : "已关闭默认轻提醒"); + reload(); + }); + } catch (Exception error) { + runOnUiThread(() -> { + setRefreshing(false); + showMessage("保存失败:" + error.getMessage()); + }); + } + }); + } + private void showMoreMenu() { new AlertDialog.Builder(this) .setItems(new CharSequence[]{"改名", "刷新"}, (dialog, which) -> { 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 30ad8f5..188e3e2 100644 --- a/android/app/src/main/java/com/hyzq/boss/ProjectChatUiState.java +++ b/android/app/src/main/java/com/hyzq/boss/ProjectChatUiState.java @@ -312,6 +312,50 @@ public final class ProjectChatUiState { return builder.toString(); } + public static String summarizeDispatchPlanCompact(@Nullable JSONObject plan) { + if (plan == null) { + return "主 Agent 暂未生成推荐线程。"; + } + List targetTitles = dispatchPlanTargetTitles(plan); + String summary = plan.optString("summary", "").trim(); + if (targetTitles.isEmpty()) { + return isBlank(summary) ? "主 Agent 已生成推荐线程。" : truncate(summary, 32); + } + if (isBlank(summary)) { + return "推荐给:" + String.join("、", targetTitles); + } + return "推荐给:" + String.join("、", targetTitles) + "\n" + truncate(summary, 32); + } + + public static String summarizeDispatchPlanLight(@Nullable JSONObject plan) { + int targetCount = dispatchPlanTargetTitles(plan).size(); + if (targetCount <= 0) { + return "主 Agent 已推荐线程"; + } + return "主 Agent 已推荐 " + targetCount + " 个线程"; + } + + private static List dispatchPlanTargetTitles(@Nullable JSONObject plan) { + List targetTitles = new ArrayList<>(); + if (plan == null) { + return targetTitles; + } + JSONArray targets = plan.optJSONArray("targets"); + if (targets != null) { + for (int i = 0; i < targets.length(); i++) { + JSONObject target = targets.optJSONObject(i); + if (target == null) { + continue; + } + String title = target.optString("threadDisplayName", "").trim(); + if (!title.isEmpty()) { + targetTitles.add(title); + } + } + } + return targetTitles; + } + public static String formatAttachmentSize(long fileSizeBytes) { if (fileSizeBytes >= 1024L * 1024L) { return String.format(java.util.Locale.US, "%.1f MB", fileSizeBytes / (1024f * 1024f)); 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 cde54b8..ae9c4d8 100644 --- a/android/app/src/main/java/com/hyzq/boss/ProjectDetailActivity.java +++ b/android/app/src/main/java/com/hyzq/boss/ProjectDetailActivity.java @@ -70,6 +70,7 @@ public class ProjectDetailActivity extends BossScreenActivity { private String currentScreenSubtitle; private String projectCollaborationMode = "development"; private String projectApprovalState = "not_required"; + private boolean lightDispatchReminderEnabled; private @Nullable JSONObject currentPendingDispatchPlan; private @Nullable JSONObject currentRejectedDispatchPlan; private ProjectChatUiState.SelectionState selectionState = ProjectChatUiState.emptySelection(); @@ -310,6 +311,7 @@ public class ProjectDetailActivity extends BossScreenActivity { projectFolderName = threadMeta == null ? "" : threadMeta.optString("folderName", ""); projectCollaborationMode = project == null ? "development" : project.optString("collaborationMode", "development"); projectApprovalState = project == null ? "not_required" : project.optString("approvalState", "not_required"); + lightDispatchReminderEnabled = project != null && project.optBoolean("lightDispatchReminderEnabled", false); 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)); @@ -530,6 +532,8 @@ public class ProjectDetailActivity extends BossScreenActivity { if (collaborationGate != null) { projectCollaborationMode = collaborationGate.optString("collaborationMode", projectCollaborationMode); projectApprovalState = collaborationGate.optString("approvalState", projectApprovalState); + lightDispatchReminderEnabled = + collaborationGate.optBoolean("lightDispatchReminderEnabled", lightDispatchReminderEnabled); } currentPendingDispatchPlan = dispatchPlan; currentRejectedDispatchPlan = null; @@ -542,7 +546,6 @@ public class ProjectDetailActivity extends BossScreenActivity { : "消息已发送,主 Agent 已给出推荐线程。" ); reload(true); - showDispatchPlanConfirmation(dispatchPlan); return; } if (waitSpec.shouldWait) { @@ -852,12 +855,28 @@ public class ProjectDetailActivity extends BossScreenActivity { container.setOrientation(LinearLayout.VERTICAL); container.addView(BossUi.buildCard( this, - "approval_required".equals(projectCollaborationMode) ? "等待你批准主 Agent 下发" : "主 Agent 推荐下发", - ProjectChatUiState.summarizeDispatchPlan(dispatchPlan), - "当前确认状态:" + describeDispatchPlanApprovalState(projectApprovalState) + lightDispatchReminderEnabled + ? ProjectChatUiState.summarizeDispatchPlanLight(dispatchPlan) + : ("approval_required".equals(projectCollaborationMode) ? "主 Agent 推荐下发" : "主 Agent 推荐"), + lightDispatchReminderEnabled + ? ProjectChatUiState.summarizeDispatchPlanCompact(dispatchPlan) + : ProjectChatUiState.summarizeDispatchPlanCompact(dispatchPlan), + lightDispatchReminderEnabled ? "轻提醒已开启" : "当前状态:" + describeDispatchPlanApprovalState(projectApprovalState) )); - Button confirmButton = BossUi.buildMiniActionButton(this, "确认下发", true); - confirmButton.setOnClickListener(v -> showDispatchPlanConfirmation(dispatchPlan)); + Button confirmButton = BossUi.buildMiniActionButton( + this, + lightDispatchReminderEnabled ? "继续下发" : "确认一下", + true + ); + confirmButton.setOnClickListener(v -> confirmDispatchPlan(dispatchPlan, false)); + if (!lightDispatchReminderEnabled) { + Button rememberButton = BossUi.buildMiniActionButton(this, "确认并记住", false); + rememberButton.setOnClickListener(v -> confirmDispatchPlan(dispatchPlan, true)); + Button rejectButton = BossUi.buildMiniActionButton(this, "拒绝", false); + rejectButton.setOnClickListener(v -> rejectDispatchPlan(dispatchPlan)); + container.addView(BossUi.buildInlineActionRow(this, confirmButton, rememberButton, rejectButton)); + return container; + } Button rejectButton = BossUi.buildMiniActionButton(this, "拒绝", false); rejectButton.setOnClickListener(v -> rejectDispatchPlan(dispatchPlan)); container.addView(BossUi.buildInlineActionRow(this, confirmButton, rejectButton)); @@ -899,22 +918,7 @@ public class ProjectDetailActivity extends BossScreenActivity { return container; } - private void showDispatchPlanConfirmation(JSONObject dispatchPlan) { - String title = "approval_required".equals(projectCollaborationMode) - ? "批准主 Agent 下发" - : "确认主 Agent 推荐"; - String message = ProjectChatUiState.summarizeDispatchPlan(dispatchPlan) - + "\n\n确认后会把任务下发到推荐线程,并把线程原始回复与主 Agent 汇总一起回到群聊。"; - new AlertDialog.Builder(this) - .setTitle(title) - .setMessage(message) - .setNegativeButton("拒绝", (dialog, which) -> rejectDispatchPlan(dispatchPlan)) - .setNeutralButton("稍后", null) - .setPositiveButton("确认下发", (dialog, which) -> confirmDispatchPlan(dispatchPlan)) - .show(); - } - - private void confirmDispatchPlan(JSONObject dispatchPlan) { + private void confirmDispatchPlan(JSONObject dispatchPlan, boolean rememberLightReminder) { String planId = dispatchPlan.optString("planId", "").trim(); if (planId.isEmpty()) { showMessage("缺少调度方案 ID"); @@ -932,7 +936,12 @@ public class ProjectDetailActivity extends BossScreenActivity { for (String approvedTargetProjectId : approvedTargetProjectIds) { approved.put(approvedTargetProjectId); } - BossApiClient.ApiResponse response = apiClient.confirmDispatchPlan(projectId, planId, approved); + BossApiClient.ApiResponse response = apiClient.confirmDispatchPlan( + projectId, + planId, + approved, + rememberLightReminder + ); if (!response.ok()) { throw new IllegalStateException(response.message()); } @@ -942,6 +951,9 @@ public class ProjectDetailActivity extends BossScreenActivity { ProjectChatUiState.resolveReplyWaitAfterDispatchConfirm(response.json); runOnUiThread(() -> { applyDispatchPlanActionResponse(response.json); + if (rememberLightReminder) { + showMessage("已确认下发,并对这个群开启默认轻提醒"); + } if (waitSpec.shouldWait) { startReplyWait( waitSpec, @@ -1011,9 +1023,6 @@ public class ProjectDetailActivity extends BossScreenActivity { applyDispatchPlanActionResponse(response.json); showMessage("主 Agent 已重新生成推荐"); reload(true); - if (nextPlan != null) { - showDispatchPlanConfirmation(nextPlan); - } }); } catch (Exception error) { runOnUiThread(() -> { @@ -2103,6 +2112,8 @@ public class ProjectDetailActivity extends BossScreenActivity { if (collaborationGate != null) { projectCollaborationMode = collaborationGate.optString("collaborationMode", projectCollaborationMode); projectApprovalState = collaborationGate.optString("approvalState", projectApprovalState); + lightDispatchReminderEnabled = + collaborationGate.optBoolean("lightDispatchReminderEnabled", lightDispatchReminderEnabled); } JSONObject plan = response.optJSONObject("plan"); if (plan != null) { diff --git a/android/app/src/test/java/com/hyzq/boss/GroupInfoActivityTest.java b/android/app/src/test/java/com/hyzq/boss/GroupInfoActivityTest.java index 199fa51..459e5fc 100644 --- a/android/app/src/test/java/com/hyzq/boss/GroupInfoActivityTest.java +++ b/android/app/src/test/java/com/hyzq/boss/GroupInfoActivityTest.java @@ -161,6 +161,29 @@ public class GroupInfoActivityTest { assertTrue(viewTreeContainsText(content, "OMX Team Runtime 当前可用,当前可切换到该后端。")); } + @Test + public void renderGroupShowsLightReminderPreferenceRow() throws Exception { + Intent intent = new Intent() + .putExtra(GroupInfoActivity.EXTRA_PROJECT_ID, "group-1") + .putExtra(GroupInfoActivity.EXTRA_PROJECT_NAME, "巡检协作群"); + TestGroupInfoActivity activity = Robolectric + .buildActivity(TestGroupInfoActivity.class, intent) + .setup() + .get(); + + ReflectionHelpers.callInstanceMethod( + activity, + "renderGroup", + ReflectionHelpers.ClassParameter.from(JSONObject.class, buildDetailPayload(true)), + ReflectionHelpers.ClassParameter.from(JSONObject.class, buildParticipantsPayload()), + ReflectionHelpers.ClassParameter.from(JSONObject.class, buildOrchestrationBackendPayload()) + ); + + LinearLayout content = activity.findViewById(R.id.screen_content); + assertTrue(viewTreeContainsText(content, "推荐下发默认轻提醒")); + assertTrue(viewTreeContainsText(content, "已开启")); + } + @Test public void renderGroupShowsOmxFallbackHintWhenOmxRuntimeIsUnavailable() throws Exception { Intent intent = new Intent() @@ -236,7 +259,38 @@ public class GroupInfoActivityTest { assertEquals("{\"requestedBackendId\":\"omx-team\"}", connection.requestBody()); } + @Test + public void saveDispatchReminderPreferenceUsesScopedEndpoint() throws Exception { + Intent intent = new Intent() + .putExtra(GroupInfoActivity.EXTRA_PROJECT_ID, "group-1") + .putExtra(GroupInfoActivity.EXTRA_PROJECT_NAME, "巡检协作群"); + TestGroupInfoActivity activity = Robolectric + .buildActivity(TestGroupInfoActivity.class, intent) + .setup() + .get(); + RecordingConnection connection = new RecordingConnection( + new URL("https://boss.hyzq.net/api/v1/projects/group-1/dispatch-reminder") + ); + ReflectionHelpers.setField(activity, "apiClient", new RecordingBossApiClient(connection)); + ReflectionHelpers.setField(activity, "executor", new DirectExecutorService()); + + ReflectionHelpers.callInstanceMethod( + activity, + "saveDispatchReminderPreference", + ReflectionHelpers.ClassParameter.from(boolean.class, true) + ); + Shadows.shadowOf(Looper.getMainLooper()).idle(); + + assertEquals("/api/v1/projects/group-1/dispatch-reminder", connection.lastPath); + assertEquals("PATCH", connection.requestMethodValue); + assertEquals("{\"lightDispatchReminderEnabled\":true}", connection.requestBody()); + } + private static JSONObject buildDetailPayload() throws Exception { + return buildDetailPayload(false); + } + + private static JSONObject buildDetailPayload(boolean lightReminderEnabled) throws Exception { JSONObject threadMeta = new JSONObject() .put("threadId", "group-thread-3") .put("folderName", "Boss"); @@ -245,6 +299,7 @@ public class GroupInfoActivityTest { .put("name", "巡检协作群") .put("isGroup", true) .put("collaborationMode", "development") + .put("lightDispatchReminderEnabled", lightReminderEnabled) .put("threadMeta", threadMeta); return new JSONObject().put("project", project); } 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 cb367d5..e22ebfd 100644 --- a/android/app/src/test/java/com/hyzq/boss/ProjectDetailActivityUiTest.java +++ b/android/app/src/test/java/com/hyzq/boss/ProjectDetailActivityUiTest.java @@ -463,10 +463,45 @@ public class ProjectDetailActivityUiTest { ReflectionHelpers.ClassParameter.from(JSONObject.class, dispatchPlan) ); - assertTrue(viewTreeContainsText(card, "确认下发")); + assertTrue(viewTreeContainsText(card, "确认一下")); + assertTrue(viewTreeContainsText(card, "确认并记住")); assertTrue(viewTreeContainsText(card, "拒绝")); } + @Test + public void pendingDispatchPlanViewUsesCompactCopyWhenLightReminderEnabled() throws Exception { + Intent intent = new Intent() + .putExtra(ProjectDetailActivity.EXTRA_PROJECT_ID, "project-group") + .putExtra(ProjectDetailActivity.EXTRA_PROJECT_NAME, "协作群"); + TestProjectDetailActivity activity = Robolectric + .buildActivity(TestProjectDetailActivity.class, intent) + .setup() + .get(); + + ReflectionHelpers.setField(activity, "projectCollaborationMode", "approval_required"); + ReflectionHelpers.setField(activity, "projectApprovalState", "pending_user"); + ReflectionHelpers.setField(activity, "lightDispatchReminderEnabled", true); + + JSONObject dispatchPlan = new JSONObject() + .put("planId", "dispatch-plan-light") + .put("summary", "建议先让 Boss 移动控制台同步树莓派二代状态。") + .put("targets", new JSONArray().put(new JSONObject() + .put("projectId", "thread-1") + .put("threadDisplayName", "Boss 移动控制台") + .put("reason", "最近活跃"))); + + View card = ReflectionHelpers.callInstanceMethod( + activity, + "buildPendingDispatchPlanView", + ReflectionHelpers.ClassParameter.from(JSONObject.class, dispatchPlan) + ); + + assertTrue(viewTreeContainsText(card, "主 Agent 已推荐 1 个线程")); + assertTrue(viewTreeContainsText(card, "继续下发")); + assertFalse(viewTreeContainsText(card, "等待你批准主 Agent 下发")); + assertFalse(viewTreeContainsText(card, "当前确认状态:")); + } + @Test public void renderProjectShowsRepairEntryForDirtyGroupAndOpensGroupInfo() throws Exception { Intent intent = new Intent() diff --git a/src/app/api/v1/projects/[projectId]/dispatch-plans/[planId]/confirm/route.ts b/src/app/api/v1/projects/[projectId]/dispatch-plans/[planId]/confirm/route.ts index 16e38b3..001411b 100644 --- a/src/app/api/v1/projects/[projectId]/dispatch-plans/[planId]/confirm/route.ts +++ b/src/app/api/v1/projects/[projectId]/dispatch-plans/[planId]/confirm/route.ts @@ -24,6 +24,7 @@ export async function POST( const body = (await request.json().catch(() => ({}))) as { approvedTargetProjectIds?: string[]; + rememberLightReminder?: boolean; }; const { projectId, planId } = await context.params; @@ -37,6 +38,7 @@ export async function POST( (item): item is string => typeof item === "string" && item.trim().length > 0, ) : [], + rememberLightReminder: body.rememberLightReminder === true, }); return NextResponse.json({ diff --git a/src/app/api/v1/projects/[projectId]/dispatch-reminder/route.ts b/src/app/api/v1/projects/[projectId]/dispatch-reminder/route.ts new file mode 100644 index 0000000..16d47c0 --- /dev/null +++ b/src/app/api/v1/projects/[projectId]/dispatch-reminder/route.ts @@ -0,0 +1,48 @@ +import { NextRequest, NextResponse } from "next/server"; +import { requireRequestSession } from "@/lib/boss-auth"; +import { + buildCollaborationGate, + getProject, + updateProjectLightDispatchReminder, +} from "@/lib/boss-data"; + +export async function PATCH( + request: NextRequest, + context: { params: Promise<{ projectId: string }> }, +) { + const session = await requireRequestSession(request); + if (!session) { + return NextResponse.json({ ok: false, message: "UNAUTHORIZED" }, { status: 401 }); + } + + const { projectId } = await context.params; + const body = (await request.json().catch(() => ({}))) as { + lightDispatchReminderEnabled?: unknown; + }; + if (typeof body.lightDispatchReminderEnabled !== "boolean") { + return NextResponse.json( + { ok: false, message: "INVALID_DISPATCH_REMINDER_PAYLOAD" }, + { status: 400 }, + ); + } + + try { + await updateProjectLightDispatchReminder({ + projectId, + requestedBy: session.account, + lightDispatchReminderEnabled: body.lightDispatchReminderEnabled, + }); + const project = await getProject(projectId); + if (!project) { + return NextResponse.json({ ok: false, message: "PROJECT_NOT_FOUND" }, { status: 404 }); + } + return NextResponse.json({ + ok: true, + project, + collaborationGate: buildCollaborationGate(project), + }); + } catch (error) { + const reason = error instanceof Error ? error.message : "UNKNOWN_ERROR"; + return NextResponse.json({ ok: false, message: reason }, { status: 400 }); + } +} diff --git a/src/app/conversations/[projectId]/page.tsx b/src/app/conversations/[projectId]/page.tsx index ef9759b..ccb3be9 100644 --- a/src/app/conversations/[projectId]/page.tsx +++ b/src/app/conversations/[projectId]/page.tsx @@ -181,6 +181,7 @@ export default async function ProjectChatPage({ (null); const [dismissedPendingPlanId, setDismissedPendingPlanId] = useState(null); + const [lightDispatchReminderEnabled, setLightDispatchReminderEnabled] = useState( + initialLightDispatchReminderEnabled, + ); const pendingDispatchPlan = localPendingDispatchPlan ?? (initialPendingDispatchPlan && initialPendingDispatchPlan.planId !== dismissedPendingPlanId @@ -1060,7 +1067,7 @@ export function ChatComposer({ const rejectedDispatchPlan = pendingDispatchPlan ? null : localRejectedDispatchPlan ?? initialRejectedDispatchPlan ?? null; - async function confirmDispatchPlan() { + async function confirmDispatchPlan(rememberLightReminder = false) { if (!pendingDispatchPlan) return; setLoading(true); const response = await fetch( @@ -1070,12 +1077,16 @@ export function ChatComposer({ headers: { "Content-Type": "application/json" }, body: JSON.stringify({ approvedTargetProjectIds: extractApprovedTargetProjectIds(pendingDispatchPlan), + rememberLightReminder, }), }, ); const result = (await response.json()) as { ok: boolean; executions?: Array; + collaborationGate?: { + lightDispatchReminderEnabled?: boolean; + }; message?: string; }; setLoading(false); @@ -1085,11 +1096,18 @@ export function ChatComposer({ return; } const executionCount = result.executions?.length ?? extractApprovedTargetProjectIds(pendingDispatchPlan).length; + setLightDispatchReminderEnabled( + result.collaborationGate?.lightDispatchReminderEnabled ?? lightDispatchReminderEnabled, + ); setLocalPendingDispatchPlan(null); setLocalRejectedDispatchPlan(null); setDismissedPendingPlanId(pendingDispatchPlan.planId); setMessageTone("success"); - setMessage(`已确认下发到 ${executionCount} 个线程。`); + setMessage( + rememberLightReminder + ? `已确认下发到 ${executionCount} 个线程,并记住这个群使用轻提醒。` + : `已确认下发到 ${executionCount} 个线程。`, + ); router.refresh(); } @@ -1112,6 +1130,7 @@ export function ChatComposer({ } | null; collaborationGate?: { requiresMasterAgentApproval?: boolean; + lightDispatchReminderEnabled?: boolean; }; message?: string; }; @@ -1132,6 +1151,9 @@ export function ChatComposer({ : null, ); setDismissedPendingPlanId(null); + setLightDispatchReminderEnabled( + result.collaborationGate?.lightDispatchReminderEnabled ?? lightDispatchReminderEnabled, + ); setMessageTone("success"); setMessage( result.collaborationGate?.requiresMasterAgentApproval @@ -1199,6 +1221,7 @@ export function ChatComposer({ } | null; collaborationGate?: { requiresMasterAgentApproval?: boolean; + lightDispatchReminderEnabled?: boolean; }; messageText?: string; }; @@ -1222,6 +1245,9 @@ export function ChatComposer({ }); setLocalRejectedDispatchPlan(null); setDismissedPendingPlanId(null); + setLightDispatchReminderEnabled( + result.collaborationGate?.lightDispatchReminderEnabled ?? lightDispatchReminderEnabled, + ); setMessage( result.collaborationGate?.requiresMasterAgentApproval ? "消息已发送,等待你批准主 Agent 下发。" @@ -1305,17 +1331,32 @@ export function ChatComposer({ ) : null} {pendingDispatchPlan ? (
-
等待你确认主 Agent 推荐
-
{summarizeDispatchPlan(pendingDispatchPlan)}
+
+ {lightDispatchReminderEnabled ? summarizeDispatchPlanLightTitle(pendingDispatchPlan) : "主 Agent 推荐下发"} +
+
{summarizeDispatchPlanCompact(pendingDispatchPlan)}
+
+ {lightDispatchReminderEnabled ? "轻提醒已开启" : "当前仍会显式提醒你确认"} +
+ {!lightDispatchReminderEnabled ? ( + + ) : null}