feat: restore dispatch confirmation flows

This commit is contained in:
kris
2026-03-30 17:11:07 +08:00
parent 40861c63da
commit 5eb1246f02
15 changed files with 823 additions and 9 deletions

View File

@@ -11,8 +11,9 @@ import {
StatusBar,
} 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 { formatTimestampLabel, getProjectDetailView } from "@/lib/boss-projections";
import { readState } from "@/lib/boss-data";
export const dynamic = "force-dynamic";
@@ -25,6 +26,9 @@ export default async function ProjectChatPage({
const { projectId } = await params;
const state = await readState();
const detail = getProjectDetailView(state, projectId);
const pendingDispatchPlan = detail?.project.isGroup
? latestPendingDispatchPlan(await listDispatchPlansByProject(projectId))
: null;
if (!detail) notFound();
@@ -147,7 +151,21 @@ export default async function ProjectChatPage({
</Link>
</div>
</div>
<ChatComposer projectId={detail.project.id} />
<ChatComposer
projectId={detail.project.id}
initialPendingDispatchPlan={
pendingDispatchPlan
? {
planId: pendingDispatchPlan.planId,
summary: pendingDispatchPlan.summary,
targets: (pendingDispatchPlan.targets ?? []).map((target) => ({
projectId: target.projectId,
threadDisplayName: target.threadDisplayName,
})),
}
: null
}
/>
</AppShell>
);
}

View File

@@ -14,6 +14,10 @@ import {
popAppHistoryEntry,
resolveAppBackAction,
} from "@/lib/boss-app-client";
import {
extractApprovedTargetProjectIds,
summarizeDispatchPlan,
} from "@/lib/dispatch-plan-ui";
import type {
Device,
DeviceEnrollment,
@@ -806,11 +810,64 @@ export function MasterIdentityPill({ identity }: { identity: MasterIdentitySumma
);
}
export function ChatComposer({ projectId }: { projectId: string }) {
type PendingDispatchPlanState = {
planId: string;
summary?: string;
targets: Array<{ projectId: string; threadDisplayName: string }>;
};
export function ChatComposer({
projectId,
initialPendingDispatchPlan,
}: {
projectId: string;
initialPendingDispatchPlan?: PendingDispatchPlanState | null;
}) {
const router = useRouter();
const [value, setValue] = useState("");
const [message, setMessage] = useState("");
const [messageTone, setMessageTone] = useState<"success" | "error">("success");
const [loading, setLoading] = useState(false);
const [localPendingDispatchPlan, setLocalPendingDispatchPlan] =
useState<PendingDispatchPlanState | null>(null);
const [dismissedPendingPlanId, setDismissedPendingPlanId] = useState<string | null>(null);
const pendingDispatchPlan =
localPendingDispatchPlan ??
(initialPendingDispatchPlan && initialPendingDispatchPlan.planId !== dismissedPendingPlanId
? initialPendingDispatchPlan
: null);
async function confirmDispatchPlan() {
if (!pendingDispatchPlan) return;
setLoading(true);
const response = await fetch(
`/api/v1/projects/${projectId}/dispatch-plans/${pendingDispatchPlan.planId}/confirm`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
approvedTargetProjectIds: extractApprovedTargetProjectIds(pendingDispatchPlan),
}),
},
);
const result = (await response.json()) as {
ok: boolean;
executions?: Array<unknown>;
message?: string;
};
setLoading(false);
if (!result.ok) {
setMessageTone("error");
setMessage(result.message ?? "确认下发失败,请重试。");
return;
}
const executionCount = result.executions?.length ?? extractApprovedTargetProjectIds(pendingDispatchPlan).length;
setLocalPendingDispatchPlan(null);
setDismissedPendingPlanId(pendingDispatchPlan.planId);
setMessageTone("success");
setMessage(`已确认下发到 ${executionCount} 个线程。`);
router.refresh();
}
async function send(kind: "text" | "voice_intent" | "image_intent" | "video_intent") {
setLoading(true);
@@ -819,7 +876,19 @@ export function ChatComposer({ projectId }: { projectId: string }) {
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ body: kind === "text" ? value : undefined, kind }),
});
const result = (await response.json()) as { ok: boolean; message?: { body: string } };
const result = (await response.json()) as {
ok: boolean;
message?: { body: string };
dispatchPlan?: {
planId: string;
summary?: string;
targets?: Array<{ projectId: string; threadDisplayName: string }>;
} | null;
collaborationGate?: {
requiresMasterAgentApproval?: boolean;
};
messageText?: string;
};
setLoading(false);
if (result.ok) {
void sendAppLog({
@@ -832,7 +901,23 @@ export function ChatComposer({ projectId }: { projectId: string }) {
mirrorToMaster: false,
});
setValue("");
setMessage("");
if (result.dispatchPlan) {
setLocalPendingDispatchPlan({
planId: result.dispatchPlan.planId,
summary: result.dispatchPlan.summary,
targets: result.dispatchPlan.targets ?? [],
});
setDismissedPendingPlanId(null);
setMessage(
result.collaborationGate?.requiresMasterAgentApproval
? "消息已发送,等待你批准主 Agent 下发。"
: "消息已发送,主 Agent 已给出推荐线程。",
);
setMessageTone("success");
} else {
setLocalPendingDispatchPlan(null);
setMessage("");
}
router.refresh();
return;
}
@@ -845,6 +930,7 @@ export function ChatComposer({ projectId }: { projectId: string }) {
detail: "Boss 会话消息接口返回失败。",
mirrorToMaster: true,
});
setMessageTone("error");
setMessage("消息发送失败,请重试。");
}
@@ -887,10 +973,44 @@ export function ChatComposer({ projectId }: { projectId: string }) {
<Link href={`/conversations/${projectId}/forward`}></Link>
</div>
{message ? (
<div className="mt-3 rounded-2xl bg-[#FFF1F0] px-4 py-3 text-[12px] text-[#CF1322]">
<div
className={clsx(
"mt-3 rounded-2xl px-4 py-3 text-[12px]",
messageTone === "success"
? "bg-[#EAF7F0] text-[#215B39]"
: "bg-[#FFF1F0] text-[#CF1322]",
)}
>
{message}
</div>
) : 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="mt-3 flex flex-wrap gap-2">
<button
type="button"
disabled={loading}
onClick={() => void confirmDispatchPlan()}
className="rounded-full bg-[#07C160] px-4 py-2 text-[13px] font-semibold text-white disabled:bg-[#B7E6C9]"
>
</button>
<button
type="button"
disabled={loading}
onClick={() => {
setLocalPendingDispatchPlan(null);
setDismissedPendingPlanId(pendingDispatchPlan.planId);
}}
className="rounded-full border border-[#D9D9D9] px-4 py-2 text-[13px] font-semibold text-[#57606A]"
>
</button>
</div>
</div>
) : null}
</div>
);
}

View File

@@ -4058,6 +4058,9 @@ function upsertDispatchPlanInState(
if (!groupProjectId) throw new Error("DISPATCH_PLAN_GROUP_PROJECT_REQUIRED");
if (!requestMessageId) throw new Error("DISPATCH_PLAN_REQUEST_MESSAGE_REQUIRED");
if (!requestedBy) throw new Error("DISPATCH_PLAN_REQUESTED_BY_REQUIRED");
const groupProject = state.projects.find((item) => item.id === groupProjectId);
if (!groupProject) throw new Error("DISPATCH_PLAN_GROUP_PROJECT_NOT_FOUND");
if (!groupProject.isGroup) throw new Error("DISPATCH_PLAN_GROUP_PROJECT_INVALID");
const validatedTargets = normalizeDispatchPlanTargetsForCreate(state, input.targets);
const existing = state.dispatchPlans.find(
@@ -4071,6 +4074,9 @@ function upsertDispatchPlanInState(
if (!payloadMatches) {
throw new Error("DISPATCH_PLAN_RETRY_MISMATCH");
}
if (groupProject.collaborationMode === "approval_required") {
groupProject.approvalState = "pending_user";
}
return existing;
}
@@ -4085,6 +4091,11 @@ function upsertDispatchPlanInState(
createdAt: nowIso(),
};
state.dispatchPlans.unshift(plan);
if (groupProject.collaborationMode === "approval_required") {
groupProject.approvalState = "pending_user";
} else {
groupProject.approvalState = "not_required";
}
return plan;
}
@@ -4163,6 +4174,8 @@ export async function createDispatchExecutionsFromPlan(input: {
if (plan.confirmedBy !== confirmedBy) {
throw new Error("DISPATCH_PLAN_CONFIRMED_BY_MISMATCH");
}
const groupProject = state.projects.find((item) => item.id === plan.groupProjectId);
if (!groupProject) throw new Error("PROJECT_NOT_FOUND");
const canonicalTargetProjectIds = normalizeStringSet(plan.confirmedTargetProjectIds);
const existingExecutions = state.dispatchExecutions.filter((item) => item.planId === plan.planId);
@@ -4174,6 +4187,8 @@ export async function createDispatchExecutionsFromPlan(input: {
if (plan.status !== "dispatched") {
plan.status = "dispatched";
}
groupProject.approvalState =
groupProject.collaborationMode === "approval_required" ? "approved" : "not_required";
ensureDispatchExecutionTasksInState(state, plan, existingExecutions);
return existingExecutions;
}
@@ -4201,6 +4216,8 @@ export async function createDispatchExecutionsFromPlan(input: {
});
ensureDispatchExecutionTasksInState(state, plan, executions);
plan.status = "dispatched";
groupProject.approvalState =
groupProject.collaborationMode === "approval_required" ? "approved" : "not_required";
return executions;
});
}
@@ -4344,6 +4361,8 @@ export async function confirmDispatchPlanAndCreateExecutions(input: {
if (plan.status !== "dispatched") {
plan.status = "dispatched";
}
groupProject.approvalState =
groupProject.collaborationMode === "approval_required" ? "approved" : "not_required";
executions = existingExecutions;
} else {
const targets = plan.targets.filter((target) =>
@@ -4368,6 +4387,8 @@ export async function confirmDispatchPlanAndCreateExecutions(input: {
return execution;
});
plan.status = "dispatched";
groupProject.approvalState =
groupProject.collaborationMode === "approval_required" ? "approved" : "not_required";
const targetSummary = executions
.map((execution) => {
const project = state.projects.find((item) => item.id === execution.targetProjectId);

View File

@@ -0,0 +1,35 @@
export type DispatchPlanUiTarget = {
projectId: string;
threadDisplayName: string;
};
export type DispatchPlanUiPayload = {
planId: string;
status?: string;
summary?: string;
targets?: DispatchPlanUiTarget[];
};
export function latestPendingDispatchPlan(plans: DispatchPlanUiPayload[] | null | undefined) {
return (plans ?? []).find((plan) => plan.status === "pending_user_confirmation") ?? null;
}
export function summarizeDispatchPlan(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 summary;
}
return `${summary}\n推荐线程${titles.join("、")}`;
}
export function extractApprovedTargetProjectIds(plan: DispatchPlanUiPayload | null | undefined) {
return (plan?.targets ?? [])
.map((target) => target.projectId?.trim() || "")
.filter(Boolean);
}