feat: harden agent onboarding and device import flows

This commit is contained in:
kris
2026-03-31 05:18:58 +08:00
parent 4aed93e90c
commit f417fe1955
18 changed files with 975 additions and 132 deletions

View File

@@ -7,6 +7,26 @@ import {
replyToMasterAgentUserMessage,
} 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":
@@ -33,6 +53,33 @@ export async function POST(
};
try {
const state = await readState();
const project = state.projects.find((item) => item.id === projectId);
const shouldCreateDispatchPlan =
project?.isGroup &&
project.id !== "master-agent" &&
(body.kind ?? "text") === "text" &&
(body.body ?? "").trim().length > 0;
if (shouldCreateDispatchPlan && project.collaborationMode === "approval_required") {
const pendingPlan = [...state.dispatchPlans]
.filter(
(plan) => plan.groupProjectId === projectId && plan.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 message = await appendProjectMessage({
projectId,
senderLabel: session.displayName || "你",
@@ -66,14 +113,6 @@ export async function POST(
}
| null = null;
const state = await readState();
const project = state.projects.find((item) => item.id === projectId);
const shouldCreateDispatchPlan =
project?.isGroup &&
project.id !== "master-agent" &&
(body.kind ?? "text") === "text" &&
message.body.trim().length > 0;
if (shouldCreateDispatchPlan) {
try {
const recommendation = await queueGroupDispatchPlan({
@@ -146,20 +185,7 @@ export async function POST(
const nextState = shouldCreateDispatchPlan ? await readState() : state;
const nextProject = nextState.projects.find((item) => item.id === projectId);
const collaborationGate = nextProject
? {
isGroup: nextProject.isGroup,
collaborationMode: nextProject.collaborationMode,
requiresMasterAgentApproval:
nextProject.isGroup && nextProject.collaborationMode === "approval_required",
approvalState: nextProject.approvalState,
}
: {
isGroup: false,
collaborationMode: "development" as const,
requiresMasterAgentApproval: false,
approvalState: "not_required" as const,
};
const collaborationGate = buildCollaborationGate(nextProject);
return NextResponse.json({
ok: true,

View File

@@ -11,11 +11,27 @@ type ImportDraftResponse = {
message?: string;
};
type FeedbackTone = "info" | "success" | "error";
type Feedback = {
tone: FeedbackTone;
text: string;
};
export type DeviceImportDraftViewCopy = {
statusTitle: string;
statusBody: string;
recommendationHint: string;
resultTitle: string;
resultBody: string;
candidateCount: number;
selectedCount: number;
recommendedCount: number;
appliedProjectNames: string[];
};
function groupCandidates(draft: DeviceImportDraft | null) {
const groups = new Map<
string,
Array<DeviceImportDraft["candidates"][number]>
>();
const groups = new Map<string, Array<DeviceImportDraft["candidates"][number]>>();
for (const candidate of draft?.candidates ?? []) {
const key = candidate.codexFolderRef?.trim() || candidate.folderRef?.trim() || candidate.folderName;
const bucket = groups.get(key) ?? [];
@@ -29,6 +45,98 @@ function groupCandidates(draft: DeviceImportDraft | null) {
}));
}
function joinProjectNames(projectNames: string[]) {
return projectNames.length > 0 ? projectNames.join("、") : "";
}
export function describeDeviceImportDraft(
draft: DeviceImportDraft | null,
resolution: DeviceImportResolution | null,
): DeviceImportDraftViewCopy {
const candidateCount = draft?.candidates.length ?? 0;
const selectedCount = draft?.selectedCandidateIds.length ?? 0;
const recommendedCount = draft?.candidates.filter((candidate) => candidate.suggestedImport).length ?? 0;
const appliedProjectNames = draft?.appliedProjectNames ?? [];
const appliedProjectCount = appliedProjectNames.length;
if (!draft) {
return {
statusTitle: "等待导入草稿",
statusBody: "先让设备完成首次 heartbeat 并上报候选线程,导入草稿就会出现在这里。",
recommendationHint: "拿到候选线程后,先从标记为推荐导入的项目开始。",
resultTitle: "导入结果",
resultBody: "生成导入建议并应用后,这里会显示真正导入到会话首页的线程。",
candidateCount,
selectedCount,
recommendedCount,
appliedProjectNames,
};
}
let statusTitle = "等待勾选";
let statusBody = "先勾选要导入的线程,再生成导入建议。";
let resultTitle = "导入建议";
let resultBody = "应用导入前,这里会先显示主 Agent 风格的导入建议。";
switch (draft.status) {
case "pending_candidates":
statusTitle = "等待候选线程";
statusBody = "设备已经就绪,等 heartbeat 带回线程候选后,就可以开始勾选。";
resultTitle = "导入结果";
resultBody = "候选线程出现后,这里会显示推荐和建议。";
break;
case "pending_selection":
statusTitle = "等待勾选";
statusBody = "先勾选想导入的线程,再生成导入建议。";
break;
case "pending_resolution":
statusTitle = "建议生成中";
statusBody = "勾选已保存,接下来会生成导入建议。";
resultTitle = "导入建议";
resultBody = "导入建议生成后,会先显示每个线程的处理方式和原因。";
break;
case "resolved":
statusTitle = "建议已生成";
statusBody = "可以先看建议,再点应用导入把线程落成会话窗口。";
resultTitle = "导入建议";
resultBody = resolution?.summary ?? "主 Agent 已给出导入建议。";
break;
case "applied":
statusTitle = "已导入";
statusBody =
appliedProjectCount > 0
? `已导入 ${appliedProjectCount} 个线程:${joinProjectNames(appliedProjectNames)}`
: "导入已完成,线程已经落到会话首页。";
resultTitle = "应用结果";
resultBody =
appliedProjectCount > 0
? `已把 ${appliedProjectCount} 个线程导入到会话首页。`
: "应用导入后,线程已经出现在会话首页。";
break;
default:
break;
}
const recommendationHint =
recommendedCount > 0
? `推荐 ${recommendedCount} 项,优先勾选带“推荐导入”的线程。`
: candidateCount > 0
? "当前没有显式推荐项,按最近活跃度挑选也可以。"
: "当前还没有可选线程。";
return {
statusTitle,
statusBody,
recommendationHint,
resultTitle,
resultBody,
candidateCount,
selectedCount,
recommendedCount,
appliedProjectNames,
};
}
export function DeviceImportDraftManager({
deviceId,
deviceName,
@@ -38,20 +146,32 @@ export function DeviceImportDraftManager({
}) {
const router = useRouter();
const [loading, setLoading] = useState(false);
const [message, setMessage] = useState("");
const [feedback, setFeedback] = useState<Feedback | null>(null);
const [draft, setDraft] = useState<DeviceImportDraft | null>(null);
const [resolution, setResolution] = useState<DeviceImportResolution | null>(null);
const [selectedCandidateIds, setSelectedCandidateIds] = useState<string[]>([]);
const loadDraft = useCallback(async () => {
setLoading(true);
const response = await fetch(`/api/v1/devices/${deviceId}/import-draft`, { cache: "no-store" });
const data = (await response.json()) as ImportDraftResponse;
setLoading(false);
setDraft(data.draft ?? null);
setResolution(data.resolution ?? null);
setSelectedCandidateIds(data.draft?.selectedCandidateIds ?? []);
setMessage(data.ok ? "" : data.message ?? "导入草稿加载失败");
try {
const response = await fetch(`/api/v1/devices/${deviceId}/import-draft`, { cache: "no-store" });
const data = (await response.json()) as ImportDraftResponse;
setDraft(data.draft ?? null);
setResolution(data.resolution ?? null);
setSelectedCandidateIds(data.draft?.selectedCandidateIds ?? []);
setFeedback(
data.ok
? null
: { tone: "error", text: data.message ?? "导入草稿加载失败" },
);
} catch (error) {
setFeedback({
tone: "error",
text: error instanceof Error ? error.message : "导入草稿加载失败",
});
} finally {
setLoading(false);
}
}, [deviceId]);
useEffect(() => {
@@ -62,6 +182,7 @@ export function DeviceImportDraftManager({
}, [loadDraft]);
const groups = useMemo(() => groupCandidates(draft), [draft]);
const copy = useMemo(() => describeDeviceImportDraft(draft, resolution), [draft, resolution]);
function toggle(candidateId: string) {
setSelectedCandidateIds((current) =>
@@ -73,67 +194,100 @@ export function DeviceImportDraftManager({
async function reviewSelection() {
setLoading(true);
const selectResponse = await fetch(`/api/v1/devices/${deviceId}/import-draft/select`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ selectedCandidateIds }),
});
const selectResult = (await selectResponse.json()) as { ok: boolean; message?: string; draft?: DeviceImportDraft };
if (!selectResult.ok) {
setLoading(false);
setMessage(selectResult.message ?? "勾选保存失败");
return;
}
try {
const selectResponse = await fetch(`/api/v1/devices/${deviceId}/import-draft/select`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ selectedCandidateIds }),
});
const selectResult = (await selectResponse.json()) as {
ok: boolean;
message?: string;
draft?: DeviceImportDraft;
};
if (!selectResult.ok) {
setFeedback({ tone: "error", text: selectResult.message ?? "勾选保存失败" });
return;
}
const reviewResponse = await fetch(`/api/v1/devices/${deviceId}/import-draft/review`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({}),
});
const reviewResult = (await reviewResponse.json()) as {
ok: boolean;
message?: string;
draft?: DeviceImportDraft;
resolution?: DeviceImportResolution;
};
setLoading(false);
if (!reviewResult.ok) {
setMessage(reviewResult.message ?? "导入建议生成失败");
return;
const reviewResponse = await fetch(`/api/v1/devices/${deviceId}/import-draft/review`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({}),
});
const reviewResult = (await reviewResponse.json()) as {
ok: boolean;
message?: string;
draft?: DeviceImportDraft;
resolution?: DeviceImportResolution;
};
if (!reviewResult.ok) {
setFeedback({ tone: "error", text: reviewResult.message ?? "导入建议生成失败" });
return;
}
setDraft(reviewResult.draft ?? selectResult.draft ?? null);
setResolution(reviewResult.resolution ?? null);
setSelectedCandidateIds(
reviewResult.draft?.selectedCandidateIds ??
selectResult.draft?.selectedCandidateIds ??
selectedCandidateIds,
);
setFeedback({ tone: "success", text: "已生成导入建议,先看推荐理由再应用导入。" });
} catch (error) {
setFeedback({
tone: "error",
text: error instanceof Error ? error.message : "导入建议生成失败",
});
} finally {
setLoading(false);
}
setDraft(reviewResult.draft ?? selectResult.draft ?? null);
setResolution(reviewResult.resolution ?? null);
setMessage("已生成导入建议。");
}
async function applyResolution() {
setLoading(true);
const response = await fetch(`/api/v1/devices/${deviceId}/import-draft/apply`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({}),
});
const result = (await response.json()) as {
ok: boolean;
message?: string;
draft?: DeviceImportDraft;
resolution?: DeviceImportResolution;
};
setLoading(false);
if (!result.ok) {
setMessage(result.message ?? "导入应用失败");
return;
try {
const response = await fetch(`/api/v1/devices/${deviceId}/import-draft/apply`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({}),
});
const result = (await response.json()) as {
ok: boolean;
message?: string;
draft?: DeviceImportDraft;
resolution?: DeviceImportResolution;
};
if (!result.ok) {
setFeedback({ tone: "error", text: result.message ?? "导入应用失败" });
return;
}
setDraft(result.draft ?? draft);
setResolution(result.resolution ?? resolution);
setSelectedCandidateIds(result.draft?.selectedCandidateIds ?? draft?.selectedCandidateIds ?? []);
setFeedback({
tone: "success",
text: result.draft?.appliedProjectNames?.length
? `已导入 ${result.draft.appliedProjectNames.length} 个线程:${joinProjectNames(result.draft.appliedProjectNames)}`
: "已把选中的项目线程导入到会话首页。",
});
router.refresh();
} catch (error) {
setFeedback({
tone: "error",
text: error instanceof Error ? error.message : "导入应用失败",
});
} finally {
setLoading(false);
}
setDraft(result.draft ?? draft);
setResolution(result.resolution ?? resolution);
setMessage("已把选中的项目线程导入到会话首页。");
router.refresh();
}
const candidateCount = copy.candidateCount;
const recommendedCount = copy.recommendedCount;
return (
<div className="space-y-3 rounded-2xl border border-[#E5E5EA] bg-white px-4 py-4">
<div className="flex items-center justify-between">
<div>
<div className="flex items-center justify-between gap-3">
<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 线
@@ -143,12 +297,21 @@ export function DeviceImportDraftManager({
type="button"
onClick={() => void loadDraft()}
disabled={loading}
className="rounded-full border border-[#D9D9D9] px-3 py-1 text-[12px] text-[#57606A]"
className="shrink-0 rounded-full border border-[#D9D9D9] px-3 py-1 text-[12px] text-[#57606A]"
>
{loading ? "刷新中" : "刷新"}
</button>
</div>
<div className="rounded-2xl bg-[#F7F8FA] px-4 py-3 text-[12px] leading-6 text-[#57606A]">
<div className="font-semibold text-[#111111]">{copy.statusTitle}</div>
<div className="mt-1 text-[#57606A]">{copy.statusBody}</div>
<div className="mt-2 text-[#8C8C8C]">
{candidateCount} · {selectedCandidateIds.length} · {recommendedCount}
</div>
<div className="mt-2 text-[#8C8C8C]">{copy.recommendationHint}</div>
</div>
{draft ? (
<div className="rounded-2xl bg-[#F7F8FA] px-4 py-3 text-[12px] leading-6 text-[#57606A]">
线{draft.candidates.length}
@@ -165,8 +328,17 @@ export function DeviceImportDraftManager({
{groups.map((group) => (
<div key={group.key} className="rounded-2xl border border-[#EAECEF] bg-[#FCFCFD] px-3 py-3">
<div className="text-[14px] font-semibold text-[#111111]">{group.folderName}</div>
<div className="mt-1 text-[12px] text-[#8C8C8C]">{group.items.length} 线</div>
<div className="flex items-center justify-between gap-2">
<div className="min-w-0">
<div className="text-[14px] font-semibold text-[#111111]">{group.folderName}</div>
<div className="mt-1 text-[12px] text-[#8C8C8C]">{group.items.length} 线</div>
</div>
{group.items.some((candidate) => candidate.suggestedImport) ? (
<span className="shrink-0 rounded-full bg-[#EAF7F0] px-2 py-0.5 text-[10px] font-semibold text-[#215B39]">
</span>
) : null}
</div>
<div className="mt-3 space-y-2">
{group.items.map((candidate) => {
const selected = selectedCandidateIds.includes(candidate.candidateId);
@@ -188,12 +360,19 @@ export function DeviceImportDraftManager({
<div className="mt-1 text-[12px] text-[#8C8C8C]">
{candidate.lastActiveAt}
</div>
{candidate.suggestedImport ? (
<div className="mt-1 text-[11px] font-semibold text-[#215B39]">
</div>
) : null}
</div>
<div className="shrink-0 text-right">
{selected ? (
<span className="rounded-full bg-[#EAF7F0] px-2 py-0.5 text-[10px] font-semibold text-[#215B39]">
</span>
) : null}
</div>
{candidate.suggestedImport ? (
<span className="rounded-full bg-[#EAF7F0] px-2 py-0.5 text-[10px] font-semibold text-[#215B39]">
</span>
) : null}
</label>
);
})}
@@ -201,6 +380,11 @@ export function DeviceImportDraftManager({
</div>
))}
<div className="rounded-2xl bg-[#F7F8FA] px-4 py-3 text-[12px] leading-6 text-[#57606A]">
<div className="font-semibold text-[#111111]">{copy.resultTitle}</div>
<div className="mt-1 text-[#57606A]">{copy.resultBody}</div>
</div>
{resolution ? (
<div className="rounded-2xl bg-[#F7F8FA] px-4 py-3 text-[12px] leading-6 text-[#57606A]">
<div className="font-semibold text-[#111111]">{resolution.summary}</div>
@@ -214,6 +398,15 @@ export function DeviceImportDraftManager({
</div>
) : null}
{draft?.appliedProjectNames?.length ? (
<div className="rounded-2xl border border-[#DCEFE5] bg-[#F3FBF6] px-4 py-3 text-[12px] leading-6 text-[#215B39]">
<div className="font-semibold text-[#111111]"></div>
<div className="mt-1">
{draft.appliedProjectNames.length} 线{joinProjectNames(draft.appliedProjectNames)}
</div>
</div>
) : null}
<div className="flex flex-wrap gap-2">
<button
type="button"
@@ -221,7 +414,7 @@ export function DeviceImportDraftManager({
disabled={loading || selectedCandidateIds.length === 0}
className="rounded-full bg-[#07C160] px-4 py-2 text-[13px] font-semibold text-white disabled:bg-[#B7E6C9]"
>
{draft?.status === "resolved" || draft?.status === "applied" ? "重新生成导入建议" : "生成导入建议"}
</button>
<button
type="button"
@@ -233,9 +426,17 @@ export function DeviceImportDraftManager({
</button>
</div>
{message ? (
<div className="rounded-2xl bg-[#EAF7F0] px-4 py-3 text-[12px] leading-6 text-[#215B39]">
{message}
{feedback ? (
<div
className={
feedback.tone === "error"
? "rounded-2xl bg-[#FDECEC] px-4 py-3 text-[12px] leading-6 text-[#9E1B1B]"
: feedback.tone === "success"
? "rounded-2xl bg-[#EAF7F0] px-4 py-3 text-[12px] leading-6 text-[#215B39]"
: "rounded-2xl bg-[#F7F8FA] px-4 py-3 text-[12px] leading-6 text-[#57606A]"
}
>
{feedback.text}
</div>
) : null}
</div>

View File

@@ -343,6 +343,7 @@ export interface DeviceImportDraft {
status: "pending_candidates" | "pending_selection" | "pending_resolution" | "resolved" | "applied";
candidates: DeviceImportCandidate[];
selectedCandidateIds: string[];
appliedProjectNames: string[];
createdAt: string;
updatedAt: string;
reviewedAt?: string;
@@ -1899,6 +1900,9 @@ function normalizeDeviceImportDraft(
selectedCandidateIds: dedupeStrings(
ensureArray(raw.selectedCandidateIds, fallback?.selectedCandidateIds ?? []),
),
appliedProjectNames: dedupeStrings(
ensureArray(raw.appliedProjectNames, fallback?.appliedProjectNames ?? []),
),
createdAt: raw.createdAt ?? fallback?.createdAt ?? nowIso(),
updatedAt: raw.updatedAt ?? fallback?.updatedAt ?? nowIso(),
reviewedAt: raw.reviewedAt ?? fallback?.reviewedAt,
@@ -5406,6 +5410,10 @@ function upsertDeviceImportDraftFromHeartbeat(
: "pending_selection",
candidates: payload.candidates,
selectedCandidateIds,
appliedProjectNames:
existing?.status === "applied" && selectedCandidateIds.length > 0
? existing.appliedProjectNames
: [],
createdAt: existing?.createdAt ?? nowIso(),
updatedAt: nowIso(),
reviewedAt: existing?.reviewedAt,
@@ -5760,6 +5768,7 @@ function upsertDeviceImportResolutionInState(
draft.reviewedAt = nowIso();
draft.reviewedBy = input.reviewedBy;
draft.resolutionId = resolution.resolutionId;
draft.appliedProjectNames = [];
state.deviceImportResolutions = [
resolution,
@@ -5786,6 +5795,7 @@ export async function selectDeviceImportCandidates(input: {
}
draft.selectedCandidateIds = nextSelected;
draft.status = "pending_resolution";
draft.appliedProjectNames = [];
draft.updatedAt = nowIso();
draft.reviewedBy = input.selectedBy;
draft.reviewedAt = undefined;
@@ -6074,6 +6084,7 @@ function applyDeviceImportResolutionInState(
resolution.appliedAt = nowIso();
resolution.appliedBy = input.appliedBy;
draft.status = "applied";
draft.appliedProjectNames = importedProjects.map((project) => project.name);
draft.updatedAt = nowIso();
return {