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

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