Files
boss/src/components/device-import-draft-manager.tsx
2026-03-31 22:38:57 +08:00

553 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useRouter } from "next/navigation";
import type { DeviceImportDraft, DeviceImportResolution, MasterAgentTask } from "@/lib/boss-data";
type ImportDraftResponse = {
ok: boolean;
draft?: DeviceImportDraft | null;
resolution?: DeviceImportResolution | null;
reviewTask?: MasterAgentTask | null;
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 isDeviceImportReviewPending(
draft: DeviceImportDraft | null,
resolution: DeviceImportResolution | null,
reviewTask: MasterAgentTask | null,
) {
if (!draft || draft.status !== "pending_resolution" || resolution) {
return false;
}
return reviewTask?.status === "queued" || reviewTask?.status === "running";
}
function groupCandidates(draft: DeviceImportDraft | null) {
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) ?? [];
bucket.push(candidate);
groups.set(key, bucket);
}
return [...groups.entries()].map(([key, items]) => ({
key,
folderName: items[0]?.folderName ?? "未命名项目",
items: [...items].sort((a, b) => b.lastActiveAt.localeCompare(a.lastActiveAt)),
}));
}
function joinProjectNames(projectNames: string[]) {
return projectNames.length > 0 ? projectNames.join("、") : "";
}
export function describeDeviceImportDraft(
draft: DeviceImportDraft | null,
resolution: DeviceImportResolution | null,
reviewTask: MasterAgentTask | null = 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":
if (isDeviceImportReviewPending(draft, resolution, reviewTask)) {
statusTitle = "主 Agent 审核中";
statusBody = "勾选已保存,主 Agent 正在整理导入建议,页面会自动刷新。";
} else if (reviewTask?.status === "failed") {
statusTitle = "建议生成失败";
statusBody = "主 Agent 这次没能生成导入建议。可以稍后重新生成,当前勾选会保留。";
} else {
statusTitle = "建议生成中";
statusBody = "勾选已保存,接下来会生成导入建议。";
}
resultTitle = "导入建议";
resultBody =
reviewTask?.status === "failed"
? "导入建议生成失败后,可以直接重新生成,不需要重新勾选。"
: "导入建议生成后,会先显示每个线程的处理方式和原因。";
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,
}: {
deviceId: string;
deviceName?: string;
}) {
const router = useRouter();
const [loading, setLoading] = useState(false);
const [feedback, setFeedback] = useState<Feedback | null>(null);
const [draft, setDraft] = useState<DeviceImportDraft | null>(null);
const [resolution, setResolution] = useState<DeviceImportResolution | null>(null);
const [reviewTask, setReviewTask] = useState<MasterAgentTask | null>(null);
const [selectedCandidateIds, setSelectedCandidateIds] = useState<string[]>([]);
const loadDraft = useCallback(async () => {
setLoading(true);
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);
setReviewTask(data.reviewTask ?? 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(() => {
const timer = window.setTimeout(() => {
void loadDraft();
}, 0);
return () => window.clearTimeout(timer);
}, [loadDraft]);
useEffect(() => {
if (!isDeviceImportReviewPending(draft, resolution, reviewTask)) {
return;
}
const timer = window.setTimeout(() => {
void loadDraft();
}, 2000);
return () => window.clearTimeout(timer);
}, [draft, resolution, reviewTask, loadDraft]);
const groups = useMemo(() => groupCandidates(draft), [draft]);
const copy = useMemo(
() => describeDeviceImportDraft(draft, resolution, reviewTask),
[draft, resolution, reviewTask],
);
function toggle(candidateId: string) {
setSelectedCandidateIds((current) =>
current.includes(candidateId)
? current.filter((item) => item !== candidateId)
: [...current, candidateId],
);
}
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);
setReviewTask(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 {
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;
}
setDraft(selectResult.draft ?? draft);
setResolution(null);
setReviewTask(null);
setSelectedCandidateIds(selectResult.draft?.selectedCandidateIds ?? selectedCandidateIds);
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;
reviewTask?: MasterAgentTask;
task?: MasterAgentTask;
};
if (!reviewResult.ok) {
setFeedback({ tone: "error", text: reviewResult.message ?? "导入建议生成失败,已保留当前勾选。" });
return;
}
setDraft(reviewResult.draft ?? selectResult.draft ?? null);
setResolution(reviewResult.resolution ?? null);
setReviewTask(reviewResult.reviewTask ?? reviewResult.task ?? null);
setSelectedCandidateIds(
reviewResult.draft?.selectedCandidateIds ??
selectResult.draft?.selectedCandidateIds ??
selectedCandidateIds,
);
setFeedback({
tone: reviewResult.resolution ? "success" : "info",
text: reviewResult.resolution
? "已生成导入建议,先看推荐理由再应用导入。"
: "已提交给主 Agent 审核,建议生成后会自动刷新。",
});
} catch (error) {
setFeedback({
tone: "error",
text: error instanceof Error ? error.message : "导入建议生成失败",
});
} finally {
setLoading(false);
}
}
async function applyResolution() {
setLoading(true);
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);
setReviewTask(null);
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);
}
}
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 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 线
</div>
</div>
<button
type="button"
onClick={() => void loadDraft()}
disabled={loading}
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}
<br />
{draft.status}
<br />
{selectedCandidateIds.length}
{reviewTask ? (
<>
<br />
{reviewTask.status}
</>
) : null}
{draft.status === "pending_candidates" ? (
<>
<br />
线线
</>
) : null}
</div>
) : (
<div className="rounded-2xl bg-[#F7F8FA] px-4 py-3 text-[12px] leading-6 text-[#57606A]">
稿线
</div>
)}
{groups.map((group) => (
<div key={group.key} className="rounded-2xl border border-[#EAECEF] bg-[#FCFCFD] px-3 py-3">
<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);
return (
<label
key={candidate.candidateId}
className="flex cursor-pointer items-start gap-3 rounded-2xl border border-[#E5E5EA] bg-white px-3 py-3"
>
<input
type="checkbox"
className="mt-1 h-4 w-4 accent-[#07C160]"
checked={selected}
onChange={() => toggle(candidate.candidateId)}
/>
<div className="min-w-0 flex-1">
<div className="truncate text-[14px] font-medium text-[#111111]">
{candidate.threadDisplayName}
</div>
<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>
</label>
);
})}
</div>
</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>
<div className="mt-2 space-y-1">
{resolution.items.map((item) => (
<div key={item.candidateId}>
{item.threadDisplayName} · {item.folderName} · {item.action}
</div>
))}
</div>
</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"
onClick={() => void reviewSelection()}
disabled={loading || selectedCandidateIds.length === 0 || isDeviceImportReviewPending(draft, resolution, reviewTask)}
className="rounded-full bg-[#07C160] px-4 py-2 text-[13px] font-semibold text-white disabled:bg-[#B7E6C9]"
>
{isDeviceImportReviewPending(draft, resolution, reviewTask)
? "主 Agent 审核中"
: draft?.status === "resolved" || draft?.status === "applied" || reviewTask?.status === "failed"
? "重新生成导入建议"
: "生成导入建议"}
</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 !== "resolved"}
className="rounded-full border border-[#D9D9D9] px-4 py-2 text-[13px] font-semibold text-[#57606A] disabled:text-[#B8B8B8]"
>
{draft?.status === "applied" ? "已导入" : "应用导入"}
</button>
</div>
{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>
);
}