feat: streamline group dispatch reminders

This commit is contained in:
kris
2026-04-04 03:00:34 +08:00
parent 425d8992ef
commit 5ebb37cbfc
13 changed files with 485 additions and 37 deletions

View File

@@ -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);

View File

@@ -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) -> {

View File

@@ -312,6 +312,50 @@ public final class ProjectChatUiState {
return builder.toString();
}
public static String summarizeDispatchPlanCompact(@Nullable JSONObject plan) {
if (plan == null) {
return "主 Agent 暂未生成推荐线程。";
}
List<String> 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<String> dispatchPlanTargetTitles(@Nullable JSONObject plan) {
List<String> 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));

View File

@@ -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) {

View File

@@ -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);
}

View File

@@ -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()

View File

@@ -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({

View File

@@ -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 });
}
}

View File

@@ -181,6 +181,7 @@ export default async function ProjectChatPage({
</div>
<ChatComposer
projectId={detail.project.id}
initialLightDispatchReminderEnabled={Boolean(detail.project.lightDispatchReminderEnabled)}
dispatchPlanRecoveryHint={dispatchPlanState.pendingDispatchPlan ? dispatchPlanState.recoveryHint : null}
initialPendingDispatchPlan={
dispatchPlanState.pendingDispatchPlan

View File

@@ -18,6 +18,8 @@ import { getMasterAgentChatMenuItems } from "@/lib/master-agent-chat-menu";
import {
extractApprovedTargetProjectIds,
summarizeDispatchPlan,
summarizeDispatchPlanCompact,
summarizeDispatchPlanLightTitle,
} from "@/lib/dispatch-plan-ui";
import type {
Device,
@@ -1036,11 +1038,13 @@ export function ChatComposer({
initialPendingDispatchPlan,
initialRejectedDispatchPlan,
dispatchPlanRecoveryHint,
initialLightDispatchReminderEnabled = false,
}: {
projectId: string;
initialPendingDispatchPlan?: PendingDispatchPlanState | null;
initialRejectedDispatchPlan?: PendingDispatchPlanState | null;
dispatchPlanRecoveryHint?: string | null;
initialLightDispatchReminderEnabled?: boolean;
}) {
const router = useRouter();
const [value, setValue] = useState("");
@@ -1052,6 +1056,9 @@ export function ChatComposer({
const [localRejectedDispatchPlan, setLocalRejectedDispatchPlan] =
useState<PendingDispatchPlanState | null>(null);
const [dismissedPendingPlanId, setDismissedPendingPlanId] = useState<string | null>(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<unknown>;
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 ? (
<div className="mt-3 rounded-2xl border border-[#E5E5EA] bg-[#F7F8FA] px-4 py-4 text-[12px] leading-6 text-[#57606A]">
<div className="text-[14px] font-semibold text-[#111111]"> Agent </div>
<div className="mt-2 whitespace-pre-line">{summarizeDispatchPlan(pendingDispatchPlan)}</div>
<div className="text-[14px] font-semibold text-[#111111]">
{lightDispatchReminderEnabled ? summarizeDispatchPlanLightTitle(pendingDispatchPlan) : "主 Agent 推荐下发"}
</div>
<div className="mt-2 whitespace-pre-line">{summarizeDispatchPlanCompact(pendingDispatchPlan)}</div>
<div className="mt-2 text-[12px] text-[#8C8C8C]">
{lightDispatchReminderEnabled ? "轻提醒已开启" : "当前仍会显式提醒你确认"}
</div>
<div className="mt-3 flex flex-wrap gap-2">
<button
type="button"
disabled={loading}
onClick={() => void confirmDispatchPlan()}
onClick={() => void confirmDispatchPlan(false)}
className="rounded-full bg-[#07C160] px-4 py-2 text-[13px] font-semibold text-white disabled:bg-[#B7E6C9]"
>
{lightDispatchReminderEnabled ? "继续下发" : "确认一下"}
</button>
{!lightDispatchReminderEnabled ? (
<button
type="button"
disabled={loading}
onClick={() => void confirmDispatchPlan(true)}
className="rounded-full border border-[#D9D9D9] px-4 py-2 text-[13px] font-semibold text-[#57606A]"
>
</button>
) : null}
<button
type="button"
disabled={loading}

View File

@@ -314,6 +314,7 @@ export interface Project {
createdByAgent: boolean;
collaborationMode: "development" | "approval_required";
approvalState: "not_required" | "pending_agent" | "pending_user" | "approved" | "rejected";
lightDispatchReminderEnabled?: boolean;
orchestrationBackendOverride?: OrchestrationBackendOverride;
agentControls?: ProjectAgentControls;
unreadCount: number;
@@ -371,7 +372,7 @@ export interface DispatchExecution {
}
export function buildCollaborationGate(
project?: Pick<Project, "isGroup" | "collaborationMode" | "approvalState">,
project?: Pick<Project, "isGroup" | "collaborationMode" | "approvalState" | "lightDispatchReminderEnabled">,
) {
if (!project) {
return {
@@ -379,6 +380,7 @@ export function buildCollaborationGate(
collaborationMode: "development" as const,
requiresMasterAgentApproval: false,
approvalState: "not_required" as const,
lightDispatchReminderEnabled: false,
};
}
@@ -387,6 +389,7 @@ export function buildCollaborationGate(
collaborationMode: project.collaborationMode,
requiresMasterAgentApproval: project.isGroup && project.collaborationMode === "approval_required",
approvalState: project.approvalState,
lightDispatchReminderEnabled: Boolean(project.lightDispatchReminderEnabled),
};
}
@@ -2783,6 +2786,7 @@ function normalizeProject(raw: Partial<Project>, fallback?: Project): Project {
createdByAgent: raw.createdByAgent ?? false,
collaborationMode: raw.collaborationMode ?? "development",
approvalState: raw.approvalState ?? "not_required",
lightDispatchReminderEnabled: raw.lightDispatchReminderEnabled ?? false,
orchestrationBackendOverride: normalizeOrchestrationBackendOverride(raw.orchestrationBackendOverride),
agentControls: normalizeProjectAgentControls(raw.agentControls),
};
@@ -3686,6 +3690,33 @@ export async function updateProjectOrchestrationBackendOverride(input: {
});
}
export async function updateProjectLightDispatchReminder(input: {
projectId: string;
requestedBy: string;
lightDispatchReminderEnabled: boolean;
}) {
return mutateStateIfChanged((state) => {
const project = state.projects.find((item) => item.id === input.projectId);
if (!project) {
throw new Error("PROJECT_NOT_FOUND");
}
if (!project.isGroup) {
throw new Error("PROJECT_NOT_GROUP_CHAT");
}
requireDispatchActorSession(state, input.requestedBy);
const nextValue = Boolean(input.lightDispatchReminderEnabled);
if (Boolean(project.lightDispatchReminderEnabled) == nextValue) {
return { result: project, changed: false };
}
project.lightDispatchReminderEnabled = nextValue;
project.updatedAt = nowIso();
project.threadMeta.updatedAt = project.updatedAt;
return { result: project, changed: true };
});
}
export async function hasPersistedProject(projectId: string) {
const rawState = await loadPersistedStateRaw();
return Array.isArray(rawState.projects) && rawState.projects.some((project) => project?.id === projectId);
@@ -5559,6 +5590,7 @@ export async function confirmDispatchPlanAndCreateExecutions(input: {
planId: string;
confirmedBy: string;
approvedTargetProjectIds: string[];
rememberLightReminder?: boolean;
}) {
const result = await mutateState((state) => {
const groupProjectId = input.groupProjectId.trim();
@@ -5566,6 +5598,11 @@ export async function confirmDispatchPlanAndCreateExecutions(input: {
const groupProject = state.projects.find((item) => item.id === groupProjectId);
if (!groupProject) throw new Error("PROJECT_NOT_FOUND");
if (!canOwnDispatchPlans(groupProject)) throw new Error("PROJECT_NOT_GROUP_CHAT");
if (input.rememberLightReminder && !groupProject.lightDispatchReminderEnabled) {
groupProject.lightDispatchReminderEnabled = true;
groupProject.updatedAt = nowIso();
groupProject.threadMeta.updatedAt = groupProject.updatedAt;
}
const plan = applyDispatchPlanConfirmationInState(state, {
planId: input.planId,

View File

@@ -71,6 +71,29 @@ export function summarizeDispatchPlan(plan: DispatchPlanUiPayload | null | undef
return `${summary}\n推荐线程${titles.join("、")}`;
}
export function summarizeDispatchPlanCompact(plan: DispatchPlanUiPayload | null | undefined) {
if (!plan) {
return "主 Agent 暂未生成推荐线程。";
}
const summary = plan.summary?.trim() || "主 Agent 已生成推荐线程。";
const titles = (plan.targets ?? [])
.map((target) => target.threadDisplayName?.trim() || "")
.filter(Boolean);
if (!titles.length) {
return truncateDispatchSummary(summary);
}
return `推荐给:${titles.join("、")}\n${truncateDispatchSummary(summary)}`;
}
export function summarizeDispatchPlanLightTitle(plan: DispatchPlanUiPayload | null | undefined) {
const count = (plan?.targets ?? []).length;
return count > 0 ? `主 Agent 已推荐 ${count} 个线程` : "主 Agent 已推荐线程";
}
function truncateDispatchSummary(summary: string) {
return summary.length > 32 ? `${summary.slice(0, 32)}` : summary;
}
export function extractApprovedTargetProjectIds(plan: DispatchPlanUiPayload | null | undefined) {
return (plan?.targets ?? [])
.map((target) => target.projectId?.trim() || "")

View File

@@ -11,6 +11,7 @@ let getDispatchPlansRoute: (typeof import("../src/app/api/v1/projects/[projectId
let confirmDispatchPlanRoute: (typeof import("../src/app/api/v1/projects/[projectId]/dispatch-plans/[planId]/confirm/route"))["POST"];
let rejectDispatchPlanRoute: (typeof import("../src/app/api/v1/projects/[projectId]/dispatch-plans/[planId]/reject/route"))["POST"];
let retryDispatchPlanRoute: (typeof import("../src/app/api/v1/projects/[projectId]/dispatch-plans/[planId]/retry/route"))["POST"];
let updateDispatchReminderRoute: (typeof import("../src/app/api/v1/projects/[projectId]/dispatch-reminder/route"))["PATCH"];
let createAuthSession: (typeof import("../src/lib/boss-data"))["createAuthSession"];
let createProjectGroupChat: (typeof import("../src/lib/boss-data"))["createProjectGroupChat"];
let isDispatchableThreadProject: (typeof import("../src/lib/boss-data"))["isDispatchableThreadProject"];
@@ -28,12 +29,13 @@ async function setup() {
process.env.BOSS_RUNTIME_ROOT = runtimeRoot;
process.env.BOSS_STATE_FILE = path.join(runtimeRoot, "boss-state.json");
const [messageModule, plansModule, confirmModule, rejectModule, retryModule, data, auth] = await Promise.all([
const [messageModule, plansModule, confirmModule, rejectModule, retryModule, reminderModule, data, auth] = await Promise.all([
import("../src/app/api/v1/projects/[projectId]/messages/route.ts"),
import("../src/app/api/v1/projects/[projectId]/dispatch-plans/route.ts"),
import("../src/app/api/v1/projects/[projectId]/dispatch-plans/[planId]/confirm/route.ts"),
import("../src/app/api/v1/projects/[projectId]/dispatch-plans/[planId]/reject/route.ts"),
import("../src/app/api/v1/projects/[projectId]/dispatch-plans/[planId]/retry/route.ts"),
import("../src/app/api/v1/projects/[projectId]/dispatch-reminder/route.ts"),
import("../src/lib/boss-data.ts"),
import("../src/lib/boss-auth.ts"),
]);
@@ -43,6 +45,7 @@ async function setup() {
confirmDispatchPlanRoute = confirmModule.POST;
rejectDispatchPlanRoute = rejectModule.POST;
retryDispatchPlanRoute = retryModule.POST;
updateDispatchReminderRoute = reminderModule.PATCH;
createAuthSession = data.createAuthSession;
createProjectGroupChat = data.createProjectGroupChat;
isDispatchableThreadProject = data.isDispatchableThreadProject;
@@ -63,7 +66,7 @@ test.beforeEach(async () => {
await writeState(structuredClone(baseState));
});
async function createAuthedRequest(url: string, method: "GET" | "POST", body?: unknown) {
async function createAuthedRequest(url: string, method: "GET" | "POST" | "PATCH", body?: unknown) {
const session = await createAuthSession({
account: "17600003315",
role: "highest_admin",
@@ -299,6 +302,39 @@ test("POST /api/v1/projects/[projectId]/dispatch-plans/[planId]/confirm confirms
assert.equal(executionTask?.orchestrationBackendLabel, "Boss Native Orchestrator");
});
test("confirming a dispatch plan with rememberLightReminder persists the group reminder preference", async () => {
const { groupProject, dispatchPlan } = await createDispatchPlanForTest();
const approvedTargetProjectId = dispatchPlan.targets[0]?.projectId;
assert.ok(approvedTargetProjectId, "expected a recommended target project");
const response = await confirmDispatchPlanRoute(
await createAuthedRequest(
`http://127.0.0.1:3000/api/v1/projects/${groupProject.id}/dispatch-plans/${dispatchPlan.planId}/confirm`,
"POST",
{
approvedTargetProjectIds: [approvedTargetProjectId],
rememberLightReminder: true,
},
),
{ params: Promise.resolve({ projectId: groupProject.id, planId: dispatchPlan.planId }) },
);
assert.equal(response.status, 200);
const payload = (await response.json()) as {
ok: boolean;
collaborationGate: {
lightDispatchReminderEnabled?: boolean;
};
};
assert.equal(payload.ok, true);
assert.equal(payload.collaborationGate.lightDispatchReminderEnabled, true);
const nextState = await readState();
const nextGroupProject = nextState.projects.find((project) => project.id === groupProject.id);
assert.ok(nextGroupProject, "expected group project to remain present");
assert.equal(nextGroupProject?.lightDispatchReminderEnabled, true);
});
test("master-agent dispatch plans can also be confirmed and create queued executions", async () => {
const { dispatchPlan } = await createMasterAgentDispatchPlanForTest();
const approvedTargetProjectId = dispatchPlan.targets[0]?.projectId;
@@ -523,3 +559,56 @@ test("retrying a rejected dispatch plan creates a fresh pending recommendation a
const nextGroupProject = nextState.projects.find((project) => project.id === groupProject.id);
assert.equal(nextGroupProject?.approvalState, "pending_user");
});
test("PATCH /api/v1/projects/[projectId]/dispatch-reminder updates the per-group light reminder preference", async () => {
const { groupProject } = await createDispatchPlanForTest();
const enableResponse = await updateDispatchReminderRoute(
await createAuthedRequest(
`http://127.0.0.1:3000/api/v1/projects/${groupProject.id}/dispatch-reminder`,
"PATCH",
{ lightDispatchReminderEnabled: true },
),
{ params: Promise.resolve({ projectId: groupProject.id }) },
);
assert.equal(enableResponse.status, 200);
const enablePayload = (await enableResponse.json()) as {
ok: boolean;
project: {
id: string;
lightDispatchReminderEnabled?: boolean;
};
};
assert.equal(enablePayload.ok, true);
assert.equal(enablePayload.project.id, groupProject.id);
assert.equal(enablePayload.project.lightDispatchReminderEnabled, true);
const disableResponse = await updateDispatchReminderRoute(
await createAuthedRequest(
`http://127.0.0.1:3000/api/v1/projects/${groupProject.id}/dispatch-reminder`,
"PATCH",
{ lightDispatchReminderEnabled: false },
),
{ params: Promise.resolve({ projectId: groupProject.id }) },
);
assert.equal(disableResponse.status, 200);
const disablePayload = (await disableResponse.json()) as {
ok: boolean;
project: {
id: string;
lightDispatchReminderEnabled?: boolean;
};
collaborationGate: {
lightDispatchReminderEnabled?: boolean;
};
};
assert.equal(disablePayload.ok, true);
assert.equal(disablePayload.project.id, groupProject.id);
assert.equal(disablePayload.project.lightDispatchReminderEnabled, false);
assert.equal(disablePayload.collaborationGate.lightDispatchReminderEnabled, false);
const nextState = await readState();
const nextGroupProject = nextState.projects.find((project) => project.id === groupProject.id);
assert.ok(nextGroupProject, "expected group project to remain present");
assert.equal(nextGroupProject?.lightDispatchReminderEnabled, false);
});