feat: add dispatch retry and import recovery flows

This commit is contained in:
kris
2026-03-31 22:10:03 +08:00
parent be31503d22
commit dcbff3cc7d
15 changed files with 776 additions and 23 deletions

View File

@@ -0,0 +1,123 @@
import { NextRequest, NextResponse } from "next/server";
import { requireRequestSession } from "@/lib/boss-auth";
import { appendProjectMessage, readState } from "@/lib/boss-data";
import { queueGroupDispatchPlan } from "@/lib/boss-master-agent";
function buildCollaborationGate(project?: {
isGroup: boolean;
collaborationMode: "development" | "approval_required";
approvalState: "not_required" | "pending_agent" | "pending_user" | "approved" | "rejected";
}) {
return project
? {
isGroup: project.isGroup,
collaborationMode: project.collaborationMode,
requiresMasterAgentApproval: project.isGroup && project.collaborationMode === "approval_required",
approvalState: project.approvalState,
}
: {
isGroup: false,
collaborationMode: "development" as const,
requiresMasterAgentApproval: false,
approvalState: "not_required" as const,
};
}
function dispatchFailureNotice(error?: string) {
switch (error) {
case "GROUP_DISPATCH_TARGETS_REQUIRED":
return "当前群聊里还没有可下发的真实线程,请先在群资料里重新添加线程后再试。";
case "DISPATCH_TARGET_PROJECT_NOT_FOUND":
return "当前群聊里有失效的线程引用,请重新整理群成员后再试。";
default:
return error ? `主 Agent 暂时无法重新生成推荐:${error}` : "主 Agent 暂时无法重新生成推荐,请稍后重试。";
}
}
export async function POST(
request: NextRequest,
context: { params: Promise<{ projectId: string; planId: string }> },
) {
const session = await requireRequestSession(request);
if (!session) {
return NextResponse.json({ ok: false, message: "UNAUTHORIZED" }, { status: 401 });
}
const { projectId, planId } = await context.params;
try {
const state = await readState();
const project = state.projects.find((item) => item.id === projectId);
if (!project) {
return NextResponse.json({ ok: false, message: "PROJECT_NOT_FOUND" }, { status: 404 });
}
if (!project.isGroup) {
return NextResponse.json({ ok: false, message: "PROJECT_NOT_GROUP_CHAT" }, { status: 400 });
}
const plan = state.dispatchPlans.find((item) => item.planId === planId);
if (!plan || plan.groupProjectId !== projectId) {
return NextResponse.json({ ok: false, message: "DISPATCH_PLAN_NOT_FOUND" }, { status: 404 });
}
if (plan.status !== "rejected") {
return NextResponse.json({ ok: false, message: "DISPATCH_PLAN_NOT_REJECTED" }, { status: 400 });
}
const pendingPlan = [...state.dispatchPlans]
.filter(
(item) => item.groupProjectId === projectId && item.status === "pending_user_confirmation",
)
.sort((left, right) => right.createdAt.localeCompare(left.createdAt))[0];
if (pendingPlan) {
return NextResponse.json(
{
ok: false,
message: "当前还有一条主 Agent 推荐等待你确认,请先确认或拒绝后再继续。",
pendingPlan,
collaborationGate: buildCollaborationGate(project),
},
{ status: 409 },
);
}
const requestMessage = project.messages.find((message) => message.id === plan.requestMessageId);
const requestText = requestMessage?.body?.trim() || plan.summary?.trim();
if (!requestText) {
return NextResponse.json(
{ ok: false, message: "DISPATCH_PLAN_REQUEST_TEXT_REQUIRED" },
{ status: 400 },
);
}
const retryMessageId = `${plan.requestMessageId}:retry:${Date.now()}`;
const recommendation = await queueGroupDispatchPlan({
groupProjectId: projectId,
requestMessageId: retryMessageId,
requestText,
requestedBy: session.account,
});
if (!recommendation.ok) {
await appendProjectMessage({
projectId,
sender: "master",
senderLabel: "主 Agent",
body: dispatchFailureNotice(recommendation.error),
kind: "system_notice",
});
}
const nextState = await readState();
const nextProject = nextState.projects.find((item) => item.id === projectId);
return NextResponse.json({
ok: true,
dispatchRecommendation: recommendation,
dispatchPlan: recommendation.dispatchPlan,
collaborationGate: buildCollaborationGate(nextProject),
});
} catch (error) {
return NextResponse.json(
{ ok: false, message: error instanceof Error ? error.message : "UNKNOWN_ERROR" },
{ status: 400 },
);
}
}

View File

@@ -12,7 +12,7 @@ import {
} from "@/components/app-ui";
import { requirePageSession } from "@/lib/boss-auth";
import { listDispatchPlansByProject, readState } from "@/lib/boss-data";
import { latestPendingDispatchPlan } from "@/lib/dispatch-plan-ui";
import { latestPendingDispatchPlan, latestRejectedDispatchPlan } from "@/lib/dispatch-plan-ui";
import { formatTimestampLabel, getProjectDetailView } from "@/lib/boss-projections";
export const dynamic = "force-dynamic";
@@ -29,6 +29,10 @@ export default async function ProjectChatPage({
const pendingDispatchPlan = detail?.project.isGroup
? latestPendingDispatchPlan(await listDispatchPlansByProject(projectId))
: null;
const rejectedDispatchPlan =
detail?.project.isGroup && !pendingDispatchPlan
? latestRejectedDispatchPlan(await listDispatchPlansByProject(projectId))
: null;
if (!detail) notFound();
@@ -165,6 +169,18 @@ export default async function ProjectChatPage({
}
: null
}
initialRejectedDispatchPlan={
rejectedDispatchPlan
? {
planId: rejectedDispatchPlan.planId,
summary: rejectedDispatchPlan.summary,
targets: (rejectedDispatchPlan.targets ?? []).map((target) => ({
projectId: target.projectId,
threadDisplayName: target.threadDisplayName,
})),
}
: null
}
/>
</AppShell>
);

View File

@@ -819,9 +819,11 @@ type PendingDispatchPlanState = {
export function ChatComposer({
projectId,
initialPendingDispatchPlan,
initialRejectedDispatchPlan,
}: {
projectId: string;
initialPendingDispatchPlan?: PendingDispatchPlanState | null;
initialRejectedDispatchPlan?: PendingDispatchPlanState | null;
}) {
const router = useRouter();
const [value, setValue] = useState("");
@@ -830,12 +832,16 @@ export function ChatComposer({
const [loading, setLoading] = useState(false);
const [localPendingDispatchPlan, setLocalPendingDispatchPlan] =
useState<PendingDispatchPlanState | null>(null);
const [localRejectedDispatchPlan, setLocalRejectedDispatchPlan] =
useState<PendingDispatchPlanState | null>(null);
const [dismissedPendingPlanId, setDismissedPendingPlanId] = useState<string | null>(null);
const pendingDispatchPlan =
localPendingDispatchPlan ??
(initialPendingDispatchPlan && initialPendingDispatchPlan.planId !== dismissedPendingPlanId
? initialPendingDispatchPlan
: null);
const rejectedDispatchPlan =
pendingDispatchPlan ? null : localRejectedDispatchPlan ?? initialRejectedDispatchPlan ?? null;
async function confirmDispatchPlan() {
if (!pendingDispatchPlan) return;
@@ -863,12 +869,102 @@ export function ChatComposer({
}
const executionCount = result.executions?.length ?? extractApprovedTargetProjectIds(pendingDispatchPlan).length;
setLocalPendingDispatchPlan(null);
setLocalRejectedDispatchPlan(null);
setDismissedPendingPlanId(pendingDispatchPlan.planId);
setMessageTone("success");
setMessage(`已确认下发到 ${executionCount} 个线程。`);
router.refresh();
}
async function retryDispatchPlan() {
if (!rejectedDispatchPlan) return;
setLoading(true);
const response = await fetch(
`/api/v1/projects/${projectId}/dispatch-plans/${rejectedDispatchPlan.planId}/retry`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
},
);
const result = (await response.json()) as {
ok: boolean;
dispatchPlan?: {
planId: string;
summary?: string;
targets?: Array<{ projectId: string; threadDisplayName: string }>;
} | null;
collaborationGate?: {
requiresMasterAgentApproval?: boolean;
};
message?: string;
};
setLoading(false);
if (!result.ok) {
setMessageTone("error");
setMessage(result.message ?? "重新生成推荐失败,请稍后重试。");
return;
}
setLocalRejectedDispatchPlan(null);
setLocalPendingDispatchPlan(
result.dispatchPlan
? {
planId: result.dispatchPlan.planId,
summary: result.dispatchPlan.summary,
targets: result.dispatchPlan.targets ?? [],
}
: null,
);
setDismissedPendingPlanId(null);
setMessageTone("success");
setMessage(
result.collaborationGate?.requiresMasterAgentApproval
? "主 Agent 已重新生成推荐,等待你确认下发。"
: "主 Agent 已重新生成推荐。",
);
router.refresh();
}
async function rejectDispatchPlan() {
if (!pendingDispatchPlan) return;
setLoading(true);
const response = await fetch(
`/api/v1/projects/${projectId}/dispatch-plans/${pendingDispatchPlan.planId}/reject`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
},
);
const result = (await response.json()) as {
ok: boolean;
plan?: {
planId: string;
summary?: string;
targets?: Array<{ projectId: string; threadDisplayName: string }>;
} | null;
message?: string;
};
setLoading(false);
if (!result.ok) {
setMessageTone("error");
setMessage(result.message ?? "拒绝失败,请稍后重试。");
return;
}
setLocalPendingDispatchPlan(null);
setLocalRejectedDispatchPlan(
result.plan
? {
planId: result.plan.planId,
summary: result.plan.summary,
targets: result.plan.targets ?? pendingDispatchPlan.targets,
}
: pendingDispatchPlan,
);
setDismissedPendingPlanId(pendingDispatchPlan.planId);
setMessageTone("success");
setMessage("已拒绝主 Agent 推荐。");
router.refresh();
}
async function send(kind: "text" | "voice_intent" | "image_intent" | "video_intent") {
setLoading(true);
const response = await fetch(`/api/v1/projects/${projectId}/messages`, {
@@ -907,6 +1003,7 @@ export function ChatComposer({
summary: result.dispatchPlan.summary,
targets: result.dispatchPlan.targets ?? [],
});
setLocalRejectedDispatchPlan(null);
setDismissedPendingPlanId(null);
setMessage(
result.collaborationGate?.requiresMasterAgentApproval
@@ -997,6 +1094,14 @@ export function ChatComposer({
>
</button>
<button
type="button"
disabled={loading}
onClick={() => void rejectDispatchPlan()}
className="rounded-full border border-[#F0B5B5] px-4 py-2 text-[13px] font-semibold text-[#CF1322]"
>
</button>
<button
type="button"
disabled={loading}
@@ -1011,6 +1116,23 @@ export function ChatComposer({
</div>
</div>
) : null}
{rejectedDispatchPlan ? (
<div className="mt-3 rounded-2xl border border-[#F3D19C] bg-[#FFF7E6] px-4 py-4 text-[12px] leading-6 text-[#8D5D00]">
<div className="text-[14px] font-semibold text-[#111111]"></div>
<div className="mt-2 whitespace-pre-line">{summarizeDispatchPlan(rejectedDispatchPlan)}</div>
<div className="mt-2"></div>
<div className="mt-3 flex flex-wrap gap-2">
<button
type="button"
disabled={loading}
onClick={() => void retryDispatchPlan()}
className="rounded-full bg-[#07C160] px-4 py-2 text-[13px] font-semibold text-white disabled:bg-[#B7E6C9]"
>
</button>
</div>
</div>
) : null}
</div>
);
}

View File

@@ -192,6 +192,37 @@ export function DeviceImportDraftManager({
);
}
async function clearSelection() {
setLoading(true);
try {
const response = await fetch(`/api/v1/devices/${deviceId}/import-draft/select`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ selectedCandidateIds: [] }),
});
const result = (await response.json()) as {
ok: boolean;
message?: string;
draft?: DeviceImportDraft;
};
if (!result.ok) {
setFeedback({ tone: "error", text: result.message ?? "清空勾选失败" });
return;
}
setDraft(result.draft ?? draft);
setResolution(null);
setSelectedCandidateIds([]);
setFeedback({ tone: "success", text: "已清空当前勾选,你可以重新选择要导入的线程。" });
} catch (error) {
setFeedback({
tone: "error",
text: error instanceof Error ? error.message : "清空勾选失败",
});
} finally {
setLoading(false);
}
}
async function reviewSelection() {
setLoading(true);
try {
@@ -209,6 +240,9 @@ export function DeviceImportDraftManager({
setFeedback({ tone: "error", text: selectResult.message ?? "勾选保存失败" });
return;
}
setDraft(selectResult.draft ?? draft);
setResolution(null);
setSelectedCandidateIds(selectResult.draft?.selectedCandidateIds ?? selectedCandidateIds);
const reviewResponse = await fetch(`/api/v1/devices/${deviceId}/import-draft/review`, {
method: "POST",
@@ -222,7 +256,7 @@ export function DeviceImportDraftManager({
resolution?: DeviceImportResolution;
};
if (!reviewResult.ok) {
setFeedback({ tone: "error", text: reviewResult.message ?? "导入建议生成失败" });
setFeedback({ tone: "error", text: reviewResult.message ?? "导入建议生成失败,已保留当前勾选。" });
return;
}
setDraft(reviewResult.draft ?? selectResult.draft ?? null);
@@ -290,7 +324,7 @@ export function DeviceImportDraftManager({
<div className="min-w-0">
<div className="text-[16px] font-semibold text-[#111111]"> Codex </div>
<div className="mt-1 text-[12px] text-[#8C8C8C]">
{deviceName ?? deviceId} heartbeat 线
{deviceName ?? deviceId} heartbeat 线
</div>
</div>
<button
@@ -319,6 +353,12 @@ export function DeviceImportDraftManager({
{draft.status}
<br />
{selectedCandidateIds.length}
{draft.status === "pending_candidates" ? (
<>
<br />
线线
</>
) : null}
</div>
) : (
<div className="rounded-2xl bg-[#F7F8FA] px-4 py-3 text-[12px] leading-6 text-[#57606A]">
@@ -416,10 +456,18 @@ export function DeviceImportDraftManager({
>
{draft?.status === "resolved" || draft?.status === "applied" ? "重新生成导入建议" : "生成导入建议"}
</button>
<button
type="button"
onClick={() => void clearSelection()}
disabled={loading || selectedCandidateIds.length === 0}
className="rounded-full border border-[#D9D9D9] px-4 py-2 text-[13px] font-semibold text-[#57606A] disabled:text-[#B8B8B8]"
>
</button>
<button
type="button"
onClick={() => void applyResolution()}
disabled={loading || !resolution || draft?.status === "applied"}
disabled={loading || !resolution || draft?.status !== "resolved"}
className="rounded-full border border-[#D9D9D9] px-4 py-2 text-[13px] font-semibold text-[#57606A] disabled:text-[#B8B8B8]"
>
{draft?.status === "applied" ? "已导入" : "应用导入"}

View File

@@ -5564,42 +5564,86 @@ function upsertDeviceImportDraftFromHeartbeat(
candidates: DeviceImportCandidate[];
},
) {
const existing = state.deviceImportDrafts.find((item) => item.deviceId === payload.deviceId);
if (payload.candidates.length === 0) {
return null;
if (existing?.status === "applied" && existing.appliedProjectNames.length > 0) {
return existing;
}
const waitingDraft = normalizeDeviceImportDraft({
draftId: existing?.draftId ?? randomToken("import-draft"),
deviceId: payload.deviceId,
enrollmentId: payload.enrollmentId ?? existing?.enrollmentId,
status: "pending_candidates",
candidates: [],
selectedCandidateIds: [],
appliedProjectNames: [],
createdAt: existing?.createdAt ?? nowIso(),
updatedAt: nowIso(),
}, existing);
waitingDraft.reviewedAt = undefined;
waitingDraft.reviewedBy = undefined;
waitingDraft.resolutionId = undefined;
state.deviceImportResolutions = state.deviceImportResolutions.filter(
(item) => item.draftId !== waitingDraft.draftId,
);
state.deviceImportDrafts = [
waitingDraft,
...state.deviceImportDrafts.filter((item) => item.draftId !== waitingDraft.draftId),
];
return waitingDraft;
}
const existing = state.deviceImportDrafts.find((item) => item.deviceId === payload.deviceId);
const selectedCandidateIds = dedupeStrings(
(existing?.selectedCandidateIds ?? []).filter((candidateId) =>
payload.candidates.some((candidate) => candidate.candidateId === candidateId),
),
);
const previousCandidateIds = existing?.candidates.map((candidate) => candidate.candidateId) ?? [];
const nextCandidateIds = payload.candidates.map((candidate) => candidate.candidateId);
const selectionChanged =
!sameStringSet(existing?.selectedCandidateIds ?? [], selectedCandidateIds) ||
!sameStringSet(previousCandidateIds, nextCandidateIds);
const keepAppliedState =
!selectionChanged &&
existing?.status === "applied" &&
Boolean(existing.resolutionId) &&
selectedCandidateIds.length > 0;
const keepResolvedState =
!selectionChanged &&
selectedCandidateIds.length > 0 &&
Boolean(existing?.resolutionId);
const nextDraft = normalizeDeviceImportDraft({
draftId: existing?.draftId ?? randomToken("import-draft"),
deviceId: payload.deviceId,
enrollmentId: payload.enrollmentId ?? existing?.enrollmentId,
status:
existing?.status === "applied" && existing.resolutionId && selectedCandidateIds.length > 0
keepAppliedState
? "applied"
: selectedCandidateIds.length > 0
? existing?.resolutionId
? keepResolvedState
? "resolved"
: "pending_resolution"
: "pending_selection",
candidates: payload.candidates,
selectedCandidateIds,
appliedProjectNames:
existing?.status === "applied" && selectedCandidateIds.length > 0
keepAppliedState
? existing.appliedProjectNames
: [],
createdAt: existing?.createdAt ?? nowIso(),
updatedAt: nowIso(),
reviewedAt: existing?.reviewedAt,
reviewedBy: existing?.reviewedBy,
resolutionId: existing?.resolutionId,
reviewedAt: keepResolvedState || keepAppliedState ? existing?.reviewedAt : undefined,
reviewedBy: keepResolvedState || keepAppliedState ? existing?.reviewedBy : undefined,
resolutionId: keepResolvedState || keepAppliedState ? existing?.resolutionId : undefined,
}, existing);
if (!keepResolvedState && !keepAppliedState) {
state.deviceImportResolutions = state.deviceImportResolutions.filter(
(item) => item.draftId !== nextDraft.draftId,
);
}
state.deviceImportDrafts = [
nextDraft,
...state.deviceImportDrafts.filter((item) => item.draftId !== nextDraft.draftId),
@@ -5652,7 +5696,8 @@ export async function upsertDeviceHeartbeat(payload: {
suggestedImport: candidate.suggestedImport ?? true,
}),
);
const shouldAutoImportLegacyProjects = normalizedCandidates.length === 0;
const reportedProjectCandidates = Array.isArray(payload.projectCandidates);
const shouldAutoImportLegacyProjects = !reportedProjectCandidates && normalizedCandidates.length === 0;
let device = existingDevice;
if (!device) {
@@ -5969,11 +6014,8 @@ export async function selectDeviceImportCandidates(input: {
const nextSelected = dedupeStrings(input.selectedCandidateIds).filter((candidateId) =>
availableCandidateIds.has(candidateId),
);
if (nextSelected.length === 0) {
throw new Error("DEVICE_IMPORT_SELECTION_REQUIRED");
}
draft.selectedCandidateIds = nextSelected;
draft.status = "pending_resolution";
draft.status = nextSelected.length > 0 ? "pending_resolution" : "pending_selection";
draft.appliedProjectNames = [];
draft.updatedAt = nowIso();
draft.reviewedBy = input.selectedBy;
@@ -6202,6 +6244,23 @@ function applyDeviceImportResolutionInState(
const device = state.devices.find((item) => item.id === input.deviceId);
if (!device) throw new Error("DEVICE_NOT_FOUND");
if (draft.status === "applied" && resolution.status === "applied") {
const importedProjects = state.projects.filter(
(project) =>
!project.isGroup &&
project.deviceIds.includes(device.id) &&
draft.appliedProjectNames.includes(project.name),
);
return {
draft: { ...draft },
resolution: { ...resolution },
importedProjects: importedProjects.map((project) => ({ ...project })),
};
}
if (draft.status !== "resolved") {
throw new Error("DEVICE_IMPORT_RESOLUTION_STALE");
}
const selectedCandidates = draft.candidates.filter((candidate) =>
draft.selectedCandidateIds.includes(candidate.candidateId),
);

View File

@@ -14,6 +14,10 @@ export function latestPendingDispatchPlan(plans: DispatchPlanUiPayload[] | null
return (plans ?? []).find((plan) => plan.status === "pending_user_confirmation") ?? null;
}
export function latestRejectedDispatchPlan(plans: DispatchPlanUiPayload[] | null | undefined) {
return (plans ?? []).find((plan) => plan.status === "rejected") ?? null;
}
export function summarizeDispatchPlan(plan: DispatchPlanUiPayload | null | undefined) {
if (!plan) {
return "主 Agent 暂未生成推荐线程。";