From 87ffe19f78e6162a484718790c7edd4663cd36e0 Mon Sep 17 00:00:00 2001 From: kris Date: Tue, 31 Mar 2026 22:38:57 +0800 Subject: [PATCH] feat: queue device import review tasks --- .../hyzq/boss/DeviceImportDraftActivity.java | 83 ++++++++++++++-- .../boss/DeviceImportDraftActivityTest.java | 55 ++++++++++- .../device-import-draft-manager.tsx | 76 +++++++++++++-- src/lib/boss-data.ts | 10 +- src/lib/boss-master-agent.ts | 97 +++---------------- tests/device-import-draft-manager.test.ts | 46 +++++++++ tests/device-import-draft.test.ts | 97 ++++++++++++++++--- 7 files changed, 347 insertions(+), 117 deletions(-) diff --git a/android/app/src/main/java/com/hyzq/boss/DeviceImportDraftActivity.java b/android/app/src/main/java/com/hyzq/boss/DeviceImportDraftActivity.java index a5fd946..acd8e88 100644 --- a/android/app/src/main/java/com/hyzq/boss/DeviceImportDraftActivity.java +++ b/android/app/src/main/java/com/hyzq/boss/DeviceImportDraftActivity.java @@ -23,7 +23,9 @@ public class DeviceImportDraftActivity extends BossScreenActivity { private String deviceName; private @Nullable JSONObject currentDraft; private @Nullable JSONObject currentResolution; + private @Nullable JSONObject currentReviewTask; private final LinkedHashSet selectedCandidateIds = new LinkedHashSet<>(); + private final Runnable reviewPollRunnable = this::reload; @Override protected void onCreate(@Nullable Bundle savedInstanceState) { @@ -48,7 +50,11 @@ public class DeviceImportDraftActivity extends BossScreenActivity { if (!response.ok()) { throw new IllegalStateException(response.message()); } - runOnUiThread(() -> applyPayload(response.json.optJSONObject("draft"), response.json.optJSONObject("resolution"))); + runOnUiThread(() -> applyPayload( + response.json.optJSONObject("draft"), + response.json.optJSONObject("resolution"), + response.json.optJSONObject("reviewTask") + )); } catch (Exception error) { runOnUiThread(() -> { setRefreshing(false); @@ -58,9 +64,10 @@ public class DeviceImportDraftActivity extends BossScreenActivity { }); } - private void applyPayload(@Nullable JSONObject draft, @Nullable JSONObject resolution) { + private void applyPayload(@Nullable JSONObject draft, @Nullable JSONObject resolution, @Nullable JSONObject reviewTask) { currentDraft = draft; currentResolution = resolution; + currentReviewTask = reviewTask; selectedCandidateIds.clear(); JSONArray selected = draft == null ? null : draft.optJSONArray("selectedCandidateIds"); if (selected != null) { @@ -74,9 +81,31 @@ public class DeviceImportDraftActivity extends BossScreenActivity { renderCurrentState(); } + @Override + protected void onDestroy() { + contentLayout.removeCallbacks(reviewPollRunnable); + super.onDestroy(); + } + + private boolean isReviewPending(@Nullable JSONObject draft, @Nullable JSONObject resolution, @Nullable JSONObject reviewTask) { + if (draft == null || resolution != null) { + return false; + } + if (!"pending_resolution".equals(draft.optString("status", ""))) { + return false; + } + if (reviewTask == null) { + return false; + } + String taskStatus = reviewTask.optString("status", ""); + return "queued".equals(taskStatus) || "running".equals(taskStatus); + } + private void renderCurrentState() { JSONObject draft = currentDraft; JSONObject resolution = currentResolution; + JSONObject reviewTask = currentReviewTask; + contentLayout.removeCallbacks(reviewPollRunnable); replaceContent(); appendContent(BossUi.buildSoftPanel( this, @@ -110,7 +139,7 @@ public class DeviceImportDraftActivity extends BossScreenActivity { appendContent(BossUi.buildCard( this, resolveStatusTitle(draft), - resolveStatusBody(draft, resolution), + resolveStatusBody(draft, resolution, reviewTask), "候选 " + candidates.length() + " · 已选 " + selectedCandidateIds.size() + " · 推荐 " + recommendedCount @@ -183,6 +212,17 @@ public class DeviceImportDraftActivity extends BossScreenActivity { } } + if (reviewTask != null) { + appendContent(BossUi.buildCard( + this, + "审核任务", + "状态:" + reviewTask.optString("status", "unknown"), + isReviewPending(draft, resolution, reviewTask) + ? "主 Agent 正在生成导入建议,页面会自动刷新。" + : "如果任务失败,可以直接重新生成导入建议。" + )); + } + JSONArray appliedProjectNames = draft.optJSONArray("appliedProjectNames"); if (appliedProjectNames != null && appliedProjectNames.length() > 0) { appendContent(BossUi.buildCard( @@ -194,7 +234,14 @@ public class DeviceImportDraftActivity extends BossScreenActivity { } Button reviewButton = BossUi.buildMiniActionButton(this, "生成导入建议", true); - reviewButton.setEnabled(!selectedCandidateIds.isEmpty()); + reviewButton.setEnabled(!selectedCandidateIds.isEmpty() && !isReviewPending(draft, resolution, reviewTask)); + if (isReviewPending(draft, resolution, reviewTask)) { + reviewButton.setText("主 Agent 审核中"); + } else if (reviewTask != null && "failed".equals(reviewTask.optString("status", ""))) { + reviewButton.setText("重新生成导入建议"); + } else if ("resolved".equals(draft.optString("status", "")) || "applied".equals(draft.optString("status", ""))) { + reviewButton.setText("重新生成导入建议"); + } reviewButton.setOnClickListener(v -> reviewSelection()); Button clearButton = BossUi.buildMiniActionButton(this, "清空勾选", false); clearButton.setEnabled(!selectedCandidateIds.isEmpty()); @@ -208,6 +255,9 @@ public class DeviceImportDraftActivity extends BossScreenActivity { applyButton.setOnClickListener(v -> applyResolution()); appendContent(BossUi.buildInlineActionRow(this, reviewButton, clearButton, applyButton)); setRefreshing(false); + if (isReviewPending(draft, resolution, reviewTask)) { + contentLayout.postDelayed(reviewPollRunnable, 2000); + } } private String resolveStatusTitle(@Nullable JSONObject draft) { @@ -233,7 +283,7 @@ public class DeviceImportDraftActivity extends BossScreenActivity { return "导入草稿"; } - private String resolveStatusBody(@Nullable JSONObject draft, @Nullable JSONObject resolution) { + private String resolveStatusBody(@Nullable JSONObject draft, @Nullable JSONObject resolution, @Nullable JSONObject reviewTask) { if (draft == null) { return "先让设备完成首次 heartbeat 并上报候选线程,导入草稿就会出现在这里。"; } @@ -245,6 +295,12 @@ public class DeviceImportDraftActivity extends BossScreenActivity { return "先勾选想导入的线程,再生成导入建议。"; } if ("pending_resolution".equals(status)) { + if (isReviewPending(draft, resolution, reviewTask)) { + return "勾选已保存,主 Agent 正在整理导入建议,页面会自动刷新。"; + } + if (reviewTask != null && "failed".equals(reviewTask.optString("status", ""))) { + return "主 Agent 这次没能生成导入建议。可以稍后重新生成,当前勾选会保留。"; + } return "勾选已保存,接下来会生成导入建议。"; } if ("resolved".equals(status)) { @@ -306,14 +362,21 @@ public class DeviceImportDraftActivity extends BossScreenActivity { throw new IllegalStateException(reviewResponse.message()); } runOnUiThread(() -> { - showMessage("已生成导入建议"); - applyPayload(reviewResponse.json.optJSONObject("draft"), reviewResponse.json.optJSONObject("resolution")); + boolean hasResolution = reviewResponse.json.optJSONObject("resolution") != null; + showMessage(hasResolution ? "已生成导入建议" : "已提交给主 Agent 审核"); + applyPayload( + reviewResponse.json.optJSONObject("draft"), + reviewResponse.json.optJSONObject("resolution"), + reviewResponse.json.optJSONObject("reviewTask") != null + ? reviewResponse.json.optJSONObject("reviewTask") + : reviewResponse.json.optJSONObject("task") + ); }); } catch (Exception error) { final JSONObject fallbackDraft = selectedDraft; runOnUiThread(() -> { if (fallbackDraft != null) { - applyPayload(fallbackDraft, null); + applyPayload(fallbackDraft, null, null); } else { setRefreshing(false); } @@ -337,7 +400,7 @@ public class DeviceImportDraftActivity extends BossScreenActivity { } runOnUiThread(() -> { showMessage("已清空当前勾选"); - applyPayload(response.json.optJSONObject("draft"), null); + applyPayload(response.json.optJSONObject("draft"), null, null); }); } catch (Exception error) { runOnUiThread(() -> { @@ -362,7 +425,7 @@ public class DeviceImportDraftActivity extends BossScreenActivity { } runOnUiThread(() -> { showMessage("已应用导入"); - applyPayload(response.json.optJSONObject("draft"), response.json.optJSONObject("resolution")); + applyPayload(response.json.optJSONObject("draft"), response.json.optJSONObject("resolution"), null); }); } catch (Exception error) { runOnUiThread(() -> { diff --git a/android/app/src/test/java/com/hyzq/boss/DeviceImportDraftActivityTest.java b/android/app/src/test/java/com/hyzq/boss/DeviceImportDraftActivityTest.java index a245346..962f5c0 100644 --- a/android/app/src/test/java/com/hyzq/boss/DeviceImportDraftActivityTest.java +++ b/android/app/src/test/java/com/hyzq/boss/DeviceImportDraftActivityTest.java @@ -36,6 +36,7 @@ public class DeviceImportDraftActivityTest { activity, "applyPayload", ReflectionHelpers.ClassParameter.from(JSONObject.class, buildPendingDraft()), + ReflectionHelpers.ClassParameter.from(JSONObject.class, null), ReflectionHelpers.ClassParameter.from(JSONObject.class, null) ); @@ -62,7 +63,8 @@ public class DeviceImportDraftActivityTest { activity, "applyPayload", ReflectionHelpers.ClassParameter.from(JSONObject.class, buildAppliedDraft()), - ReflectionHelpers.ClassParameter.from(JSONObject.class, buildAppliedResolution()) + ReflectionHelpers.ClassParameter.from(JSONObject.class, buildAppliedResolution()), + ReflectionHelpers.ClassParameter.from(JSONObject.class, null) ); View content = activity.findViewById(R.id.screen_content); @@ -73,6 +75,32 @@ public class DeviceImportDraftActivityTest { assertTrue(viewTreeContainsText(content, "已导入")); } + @Test + public void renderCurrentStateShowsQueuedReviewTaskCopy() throws Exception { + TestDeviceImportDraftActivity activity = Robolectric + .buildActivity( + TestDeviceImportDraftActivity.class, + new Intent() + .putExtra(DeviceImportDraftActivity.EXTRA_DEVICE_ID, "device-1") + .putExtra(DeviceImportDraftActivity.EXTRA_DEVICE_NAME, "Mac Studio") + ) + .setup() + .get(); + + ReflectionHelpers.callInstanceMethod( + activity, + "applyPayload", + ReflectionHelpers.ClassParameter.from(JSONObject.class, buildPendingResolutionDraft()), + ReflectionHelpers.ClassParameter.from(JSONObject.class, null), + ReflectionHelpers.ClassParameter.from(JSONObject.class, buildQueuedReviewTask()) + ); + + View content = activity.findViewById(R.id.screen_content); + assertTrue(viewTreeContainsText(content, "主 Agent 审核中")); + assertTrue(viewTreeContainsText(content, "审核任务")); + assertTrue(viewTreeContainsText(content, "状态:queued")); + } + private static JSONObject buildPendingDraft() throws Exception { return new JSONObject() .put("draftId", "draft-1") @@ -125,6 +153,24 @@ public class DeviceImportDraftActivityTest { .put("suggestedImport", true))); } + private static JSONObject buildPendingResolutionDraft() throws Exception { + return new JSONObject() + .put("draftId", "draft-1") + .put("deviceId", "device-1") + .put("status", "pending_resolution") + .put("selectedCandidateIds", new JSONArray().put("candidate-1")) + .put("appliedProjectNames", new JSONArray()) + .put("candidates", new JSONArray() + .put(new JSONObject() + .put("candidateId", "candidate-1") + .put("deviceId", "device-1") + .put("folderName", "北区试产线") + .put("threadId", "thread-1") + .put("threadDisplayName", "北区试产线回归") + .put("lastActiveAt", "2026-03-30T10:18:00+08:00") + .put("suggestedImport", true))); + } + private static JSONObject buildAppliedResolution() throws Exception { return new JSONObject() .put("resolutionId", "resolution-1") @@ -147,6 +193,13 @@ public class DeviceImportDraftActivityTest { .put("reason", "作为独立聊天窗口导入。"))); } + private static JSONObject buildQueuedReviewTask() throws Exception { + return new JSONObject() + .put("taskId", "mastertask-1") + .put("taskType", "device_import_resolution") + .put("status", "queued"); + } + private static boolean viewTreeContainsText(View root, String expectedText) { if (root instanceof TextView) { CharSequence text = ((TextView) root).getText(); diff --git a/src/components/device-import-draft-manager.tsx b/src/components/device-import-draft-manager.tsx index 8245fda..e57dbfa 100644 --- a/src/components/device-import-draft-manager.tsx +++ b/src/components/device-import-draft-manager.tsx @@ -2,12 +2,13 @@ import { useCallback, useEffect, useMemo, useState } from "react"; import { useRouter } from "next/navigation"; -import type { DeviceImportDraft, DeviceImportResolution } from "@/lib/boss-data"; +import type { DeviceImportDraft, DeviceImportResolution, MasterAgentTask } from "@/lib/boss-data"; type ImportDraftResponse = { ok: boolean; draft?: DeviceImportDraft | null; resolution?: DeviceImportResolution | null; + reviewTask?: MasterAgentTask | null; message?: string; }; @@ -30,6 +31,17 @@ export type DeviceImportDraftViewCopy = { 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>(); for (const candidate of draft?.candidates ?? []) { @@ -52,6 +64,7 @@ function joinProjectNames(projectNames: string[]) { 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; @@ -90,10 +103,21 @@ export function describeDeviceImportDraft( statusBody = "先勾选想导入的线程,再生成导入建议。"; break; case "pending_resolution": - statusTitle = "建议生成中"; - statusBody = "勾选已保存,接下来会生成导入建议。"; + if (isDeviceImportReviewPending(draft, resolution, reviewTask)) { + statusTitle = "主 Agent 审核中"; + statusBody = "勾选已保存,主 Agent 正在整理导入建议,页面会自动刷新。"; + } else if (reviewTask?.status === "failed") { + statusTitle = "建议生成失败"; + statusBody = "主 Agent 这次没能生成导入建议。可以稍后重新生成,当前勾选会保留。"; + } else { + statusTitle = "建议生成中"; + statusBody = "勾选已保存,接下来会生成导入建议。"; + } resultTitle = "导入建议"; - resultBody = "导入建议生成后,会先显示每个线程的处理方式和原因。"; + resultBody = + reviewTask?.status === "failed" + ? "导入建议生成失败后,可以直接重新生成,不需要重新勾选。" + : "导入建议生成后,会先显示每个线程的处理方式和原因。"; break; case "resolved": statusTitle = "建议已生成"; @@ -149,6 +173,7 @@ export function DeviceImportDraftManager({ const [feedback, setFeedback] = useState(null); const [draft, setDraft] = useState(null); const [resolution, setResolution] = useState(null); + const [reviewTask, setReviewTask] = useState(null); const [selectedCandidateIds, setSelectedCandidateIds] = useState([]); const loadDraft = useCallback(async () => { @@ -158,6 +183,7 @@ export function DeviceImportDraftManager({ 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 @@ -181,8 +207,21 @@ export function DeviceImportDraftManager({ 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), [draft, resolution]); + const copy = useMemo( + () => describeDeviceImportDraft(draft, resolution, reviewTask), + [draft, resolution, reviewTask], + ); function toggle(candidateId: string) { setSelectedCandidateIds((current) => @@ -211,6 +250,7 @@ export function DeviceImportDraftManager({ } setDraft(result.draft ?? draft); setResolution(null); + setReviewTask(null); setSelectedCandidateIds([]); setFeedback({ tone: "success", text: "已清空当前勾选,你可以重新选择要导入的线程。" }); } catch (error) { @@ -242,6 +282,7 @@ export function DeviceImportDraftManager({ } setDraft(selectResult.draft ?? draft); setResolution(null); + setReviewTask(null); setSelectedCandidateIds(selectResult.draft?.selectedCandidateIds ?? selectedCandidateIds); const reviewResponse = await fetch(`/api/v1/devices/${deviceId}/import-draft/review`, { @@ -254,6 +295,8 @@ export function DeviceImportDraftManager({ message?: string; draft?: DeviceImportDraft; resolution?: DeviceImportResolution; + reviewTask?: MasterAgentTask; + task?: MasterAgentTask; }; if (!reviewResult.ok) { setFeedback({ tone: "error", text: reviewResult.message ?? "导入建议生成失败,已保留当前勾选。" }); @@ -261,12 +304,18 @@ export function DeviceImportDraftManager({ } 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: "success", text: "已生成导入建议,先看推荐理由再应用导入。" }); + setFeedback({ + tone: reviewResult.resolution ? "success" : "info", + text: reviewResult.resolution + ? "已生成导入建议,先看推荐理由再应用导入。" + : "已提交给主 Agent 审核,建议生成后会自动刷新。", + }); } catch (error) { setFeedback({ tone: "error", @@ -297,6 +346,7 @@ export function DeviceImportDraftManager({ } setDraft(result.draft ?? draft); setResolution(result.resolution ?? resolution); + setReviewTask(null); setSelectedCandidateIds(result.draft?.selectedCandidateIds ?? draft?.selectedCandidateIds ?? []); setFeedback({ tone: "success", @@ -353,6 +403,12 @@ export function DeviceImportDraftManager({ 当前状态:{draft.status}
已勾选:{selectedCandidateIds.length} + {reviewTask ? ( + <> +
+ 审核任务:{reviewTask.status} + + ) : null} {draft.status === "pending_candidates" ? ( <>
@@ -451,10 +507,14 @@ export function DeviceImportDraftManager({