feat: harden agent onboarding and device import flows
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user