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

@@ -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" ? "已导入" : "应用导入"}