From dcbff3cc7d20ef843547bcc6cb248aecf615d111 Mon Sep 17 00:00:00 2001 From: kris Date: Tue, 31 Mar 2026 22:10:03 +0800 Subject: [PATCH] feat: add dispatch retry and import recovery flows --- .../java/com/hyzq/boss/BossApiClient.java | 10 ++ .../hyzq/boss/DeviceImportDraftActivity.java | 43 +++++- .../com/hyzq/boss/ProjectChatUiState.java | 17 +++ .../com/hyzq/boss/ProjectDetailActivity.java | 63 ++++++++- .../boss/BossApiClientDispatchPlansTest.java | 15 +++ .../boss/ProjectDetailActivityUiTest.java | 41 ++++++ .../dispatch-plans/[planId]/retry/route.ts | 123 ++++++++++++++++++ src/app/conversations/[projectId]/page.tsx | 18 ++- src/components/app-ui.tsx | 122 +++++++++++++++++ .../device-import-draft-manager.tsx | 54 +++++++- src/lib/boss-data.ts | 85 ++++++++++-- src/lib/dispatch-plan-ui.ts | 4 + tests/device-import-draft.test.ts | 112 ++++++++++++++++ tests/dispatch-plan-confirmation.test.ts | 67 +++++++++- tests/dispatch-plan-ui.test.ts | 25 ++++ 15 files changed, 776 insertions(+), 23 deletions(-) create mode 100644 src/app/api/v1/projects/[projectId]/dispatch-plans/[planId]/retry/route.ts diff --git a/android/app/src/main/java/com/hyzq/boss/BossApiClient.java b/android/app/src/main/java/com/hyzq/boss/BossApiClient.java index ed42039..60c18ab 100644 --- a/android/app/src/main/java/com/hyzq/boss/BossApiClient.java +++ b/android/app/src/main/java/com/hyzq/boss/BossApiClient.java @@ -137,6 +137,16 @@ public class BossApiClient { ); } + public ApiResponse retryDispatchPlan(String projectId, String planId) throws IOException, JSONException { + return requestWithRestoreRaw( + "POST", + "/api/v1/projects/" + encode(projectId) + "/dispatch-plans/" + encode(planId) + "/retry", + new JSONObject().toString(), + DEFAULT_CONNECT_TIMEOUT_MS, + CHAT_FLOW_READ_TIMEOUT_MS + ); + } + public ApiResponse renameConversation(String projectId, String name, boolean group) throws IOException, JSONException { JSONObject payload = new JSONObject(); payload.put("name", name); 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 5cfdb8f..a5fd946 100644 --- a/android/app/src/main/java/com/hyzq/boss/DeviceImportDraftActivity.java +++ b/android/app/src/main/java/com/hyzq/boss/DeviceImportDraftActivity.java @@ -95,7 +95,7 @@ public class DeviceImportDraftActivity extends BossScreenActivity { JSONArray candidates = draft.optJSONArray("candidates"); if (candidates == null || candidates.length() == 0) { - appendContent(BossUi.buildEmptyCard(this, "当前还没有可导入的线程。")); + appendContent(BossUi.buildEmptyCard(this, "设备已在线,但当前还没有发现可导入线程。可以稍后刷新重试。")); setRefreshing(false); return; } @@ -196,14 +196,17 @@ public class DeviceImportDraftActivity extends BossScreenActivity { Button reviewButton = BossUi.buildMiniActionButton(this, "生成导入建议", true); reviewButton.setEnabled(!selectedCandidateIds.isEmpty()); reviewButton.setOnClickListener(v -> reviewSelection()); + Button clearButton = BossUi.buildMiniActionButton(this, "清空勾选", false); + clearButton.setEnabled(!selectedCandidateIds.isEmpty()); + clearButton.setOnClickListener(v -> clearSelection()); Button applyButton = BossUi.buildMiniActionButton( this, "applied".equals(draft.optString("status", "")) ? "已导入" : "应用导入", false ); - applyButton.setEnabled(resolution != null && !"applied".equals(draft.optString("status", ""))); + applyButton.setEnabled(resolution != null && "resolved".equals(draft.optString("status", ""))); applyButton.setOnClickListener(v -> applyResolution()); - appendContent(BossUi.buildInlineActionRow(this, reviewButton, applyButton)); + appendContent(BossUi.buildInlineActionRow(this, reviewButton, clearButton, applyButton)); setRefreshing(false); } @@ -287,6 +290,7 @@ public class DeviceImportDraftActivity extends BossScreenActivity { } setRefreshing(true); executor.execute(() -> { + JSONObject selectedDraft = null; try { JSONArray selected = new JSONArray(); for (String candidateId : selectedCandidateIds) { @@ -296,6 +300,7 @@ public class DeviceImportDraftActivity extends BossScreenActivity { if (!selectResponse.ok()) { throw new IllegalStateException(selectResponse.message()); } + selectedDraft = selectResponse.json.optJSONObject("draft"); BossApiClient.ApiResponse reviewResponse = apiClient.reviewDeviceImportDraft(deviceId); if (!reviewResponse.ok()) { throw new IllegalStateException(reviewResponse.message()); @@ -304,10 +309,40 @@ public class DeviceImportDraftActivity extends BossScreenActivity { showMessage("已生成导入建议"); applyPayload(reviewResponse.json.optJSONObject("draft"), reviewResponse.json.optJSONObject("resolution")); }); + } catch (Exception error) { + final JSONObject fallbackDraft = selectedDraft; + runOnUiThread(() -> { + if (fallbackDraft != null) { + applyPayload(fallbackDraft, null); + } else { + setRefreshing(false); + } + showMessage("导入建议生成失败:" + error.getMessage()); + }); + } + }); + } + + private void clearSelection() { + if (deviceId == null || deviceId.isEmpty()) { + showMessage("缺少 deviceId"); + return; + } + setRefreshing(true); + executor.execute(() -> { + try { + BossApiClient.ApiResponse response = apiClient.selectDeviceImportCandidates(deviceId, new JSONArray()); + if (!response.ok()) { + throw new IllegalStateException(response.message()); + } + runOnUiThread(() -> { + showMessage("已清空当前勾选"); + applyPayload(response.json.optJSONObject("draft"), null); + }); } catch (Exception error) { runOnUiThread(() -> { setRefreshing(false); - showMessage("导入建议生成失败:" + error.getMessage()); + showMessage("清空勾选失败:" + error.getMessage()); }); } }); diff --git a/android/app/src/main/java/com/hyzq/boss/ProjectChatUiState.java b/android/app/src/main/java/com/hyzq/boss/ProjectChatUiState.java index 6c0ed7c..30ad8f5 100644 --- a/android/app/src/main/java/com/hyzq/boss/ProjectChatUiState.java +++ b/android/app/src/main/java/com/hyzq/boss/ProjectChatUiState.java @@ -245,6 +245,23 @@ public final class ProjectChatUiState { return null; } + @Nullable + public static JSONObject latestRejectedDispatchPlan(@Nullable JSONArray plans) { + if (plans == null || plans.length() == 0) { + return null; + } + for (int i = 0; i < plans.length(); i++) { + JSONObject plan = plans.optJSONObject(i); + if (plan == null) { + continue; + } + if ("rejected".equals(plan.optString("status", ""))) { + return plan; + } + } + return null; + } + public static List dispatchPlanApprovedTargetIds(@Nullable JSONObject plan) { ArrayList approved = new ArrayList<>(); if (plan == null) { diff --git a/android/app/src/main/java/com/hyzq/boss/ProjectDetailActivity.java b/android/app/src/main/java/com/hyzq/boss/ProjectDetailActivity.java index f74a492..19ad7a3 100644 --- a/android/app/src/main/java/com/hyzq/boss/ProjectDetailActivity.java +++ b/android/app/src/main/java/com/hyzq/boss/ProjectDetailActivity.java @@ -70,6 +70,7 @@ public class ProjectDetailActivity extends BossScreenActivity { private String projectCollaborationMode = "development"; private String projectApprovalState = "not_required"; private @Nullable JSONObject currentPendingDispatchPlan; + private @Nullable JSONObject currentRejectedDispatchPlan; private ProjectChatUiState.SelectionState selectionState = ProjectChatUiState.emptySelection(); private ActivityResultLauncher conversationInfoLauncher; private ActivityResultLauncher forwardTargetLauncher; @@ -294,6 +295,9 @@ public class ProjectDetailActivity extends BossScreenActivity { currentAgentModelOverride = normalizeControlValue(agentControls == null ? null : agentControls.optString("modelOverride", null)); currentReasoningEffortOverride = normalizeControlValue(agentControls == null ? null : agentControls.optString("reasoningEffortOverride", null)); currentPendingDispatchPlan = ProjectChatUiState.latestPendingDispatchPlan(dispatchPlans); + currentRejectedDispatchPlan = currentPendingDispatchPlan == null + ? ProjectChatUiState.latestRejectedDispatchPlan(dispatchPlans) + : null; conversationInfoReady = project != null; updateProjectHeader(title, buildProjectSubtitle(projectFolderName, devices)); @@ -302,6 +306,8 @@ public class ProjectDetailActivity extends BossScreenActivity { pendingOutgoingBubble = null; if (currentPendingDispatchPlan != null) { appendContent(buildPendingDispatchPlanView(currentPendingDispatchPlan)); + } else if (projectIsGroup && "rejected".equals(projectApprovalState) && currentRejectedDispatchPlan != null) { + appendContent(buildRejectedDispatchPlanView(currentRejectedDispatchPlan)); } if (projectIsGroup && participantsPayload != null && participantsPayload.optBoolean("repairRequired", false)) { appendContent(buildRepairGroupMembersView(participantsPayload)); @@ -505,6 +511,7 @@ public class ProjectDetailActivity extends BossScreenActivity { projectApprovalState = collaborationGate.optString("approvalState", projectApprovalState); } currentPendingDispatchPlan = dispatchPlan; + currentRejectedDispatchPlan = null; if (dispatchPlan != null) { composerSending = false; updateComposerSendButtonState(); @@ -790,6 +797,21 @@ public class ProjectDetailActivity extends BossScreenActivity { return container; } + private View buildRejectedDispatchPlanView(JSONObject dispatchPlan) { + LinearLayout container = new LinearLayout(this); + container.setOrientation(LinearLayout.VERTICAL); + container.addView(BossUi.buildCard( + this, + "上次推荐已拒绝", + ProjectChatUiState.summarizeDispatchPlan(dispatchPlan), + "如果还想继续当前协作,可以重新生成推荐。" + )); + Button retryButton = BossUi.buildMiniActionButton(this, "重新生成推荐", true); + retryButton.setOnClickListener(v -> retryDispatchPlan(dispatchPlan)); + container.addView(BossUi.buildInlineActionRow(this, retryButton)); + return container; + } + private View buildRepairGroupMembersView(JSONObject participantsPayload) { String repairReason = participantsPayload.optString("repairReason", "当前群聊里有失效线程,请先修复群成员。"); int invalidParticipantCount = participantsPayload.optInt("invalidParticipantCount", 0); @@ -902,6 +924,39 @@ public class ProjectDetailActivity extends BossScreenActivity { }); } + private void retryDispatchPlan(JSONObject dispatchPlan) { + String planId = dispatchPlan.optString("planId", "").trim(); + if (planId.isEmpty()) { + showMessage("缺少调度方案 ID"); + return; + } + setRefreshing(true); + executor.execute(() -> { + try { + BossApiClient.ApiResponse response = apiClient.retryDispatchPlan(projectId, planId); + if (!response.ok()) { + throw new IllegalStateException(response.message()); + } + JSONObject nextPlan = response.json.optJSONObject("dispatchPlan"); + runOnUiThread(() -> { + currentRejectedDispatchPlan = null; + currentPendingDispatchPlan = nextPlan; + applyDispatchPlanActionResponse(response.json); + showMessage("主 Agent 已重新生成推荐"); + reload(true); + if (nextPlan != null) { + showDispatchPlanConfirmation(nextPlan); + } + }); + } catch (Exception error) { + runOnUiThread(() -> { + setRefreshing(false); + showMessage("重新生成推荐失败:" + error.getMessage()); + }); + } + }); + } + private View buildMessageView(JSONObject message) { String messageId = message.optString("id", ""); String sender = message.optString("sender", ""); @@ -1946,8 +2001,14 @@ public class ProjectDetailActivity extends BossScreenActivity { JSONObject plan = response.optJSONObject("plan"); if (plan != null) { String status = plan.optString("status", ""); - if (!"pending_user_confirmation".equals(status)) { + if ("pending_user_confirmation".equals(status)) { + currentPendingDispatchPlan = plan; + currentRejectedDispatchPlan = null; + } else { currentPendingDispatchPlan = null; + if ("rejected".equals(status)) { + currentRejectedDispatchPlan = plan; + } } } } diff --git a/android/app/src/test/java/com/hyzq/boss/BossApiClientDispatchPlansTest.java b/android/app/src/test/java/com/hyzq/boss/BossApiClientDispatchPlansTest.java index 52a12ed..905bccb 100644 --- a/android/app/src/test/java/com/hyzq/boss/BossApiClientDispatchPlansTest.java +++ b/android/app/src/test/java/com/hyzq/boss/BossApiClientDispatchPlansTest.java @@ -69,6 +69,21 @@ public class BossApiClientDispatchPlansTest { assertEquals("{}", connection.requestBody()); } + @Test + public void retryDispatchPlanUsesProjectScopedRetryEndpoint() throws Exception { + RecordingConnection connection = new RecordingConnection(new URL("https://boss.hyzq.net/api/v1/projects/p1/dispatch-plans/plan-1/retry")); + RecordingBossApiClient apiClient = new RecordingBossApiClient(connection); + + BossApiClient.ApiResponse response = apiClient.retryDispatchPlan("p1", "plan-1"); + + assertEquals(200, response.statusCode); + assertEquals("/api/v1/projects/p1/dispatch-plans/plan-1/retry", apiClient.lastPath); + assertEquals("POST", connection.requestMethodValue); + assertEquals(12000, connection.connectTimeoutValue); + assertEquals(65000, connection.readTimeoutValue); + assertEquals("{}", connection.requestBody()); + } + @Test public void getProjectAgentControlsUsesScopedEndpoint() throws Exception { RecordingConnection connection = new RecordingConnection(new URL("https://boss.hyzq.net/api/v1/projects/master-agent/agent-controls")); diff --git a/android/app/src/test/java/com/hyzq/boss/ProjectDetailActivityUiTest.java b/android/app/src/test/java/com/hyzq/boss/ProjectDetailActivityUiTest.java index 2566c36..27e162a 100644 --- a/android/app/src/test/java/com/hyzq/boss/ProjectDetailActivityUiTest.java +++ b/android/app/src/test/java/com/hyzq/boss/ProjectDetailActivityUiTest.java @@ -392,6 +392,47 @@ public class ProjectDetailActivityUiTest { assertEquals(null, ReflectionHelpers.getField(activity, "currentPendingDispatchPlan")); } + @Test + public void applyDispatchPlanActionResponseStoresRejectedPlanForRecovery() throws Exception { + Intent intent = new Intent() + .putExtra(ProjectDetailActivity.EXTRA_PROJECT_ID, "group-1") + .putExtra(ProjectDetailActivity.EXTRA_PROJECT_NAME, "巡检协作群"); + TestProjectDetailActivity activity = Robolectric + .buildActivity(TestProjectDetailActivity.class, intent) + .setup() + .get(); + + ReflectionHelpers.setField(activity, "projectCollaborationMode", "approval_required"); + ReflectionHelpers.setField(activity, "projectApprovalState", "pending_user"); + ReflectionHelpers.setField( + activity, + "currentPendingDispatchPlan", + new JSONObject().put("planId", "dispatch-plan-1").put("status", "pending_user_confirmation") + ); + + JSONObject rejectedPlan = new JSONObject() + .put("planId", "dispatch-plan-1") + .put("status", "rejected"); + JSONObject response = new JSONObject() + .put("plan", rejectedPlan) + .put("collaborationGate", new JSONObject() + .put("isGroup", true) + .put("collaborationMode", "approval_required") + .put("requiresMasterAgentApproval", true) + .put("approvalState", "rejected")); + + ReflectionHelpers.callInstanceMethod( + activity, + "applyDispatchPlanActionResponse", + ReflectionHelpers.ClassParameter.from(JSONObject.class, response) + ); + + assertEquals("rejected", ReflectionHelpers.getField(activity, "projectApprovalState")); + assertEquals(null, ReflectionHelpers.getField(activity, "currentPendingDispatchPlan")); + JSONObject storedRejected = ReflectionHelpers.getField(activity, "currentRejectedDispatchPlan"); + assertEquals("dispatch-plan-1", storedRejected.optString("planId")); + } + private static JSONObject buildGroupProjectPayload() throws Exception { JSONObject threadMeta = new JSONObject() .put("threadId", "group-thread-3") diff --git a/src/app/api/v1/projects/[projectId]/dispatch-plans/[planId]/retry/route.ts b/src/app/api/v1/projects/[projectId]/dispatch-plans/[planId]/retry/route.ts new file mode 100644 index 0000000..3bb6fc0 --- /dev/null +++ b/src/app/api/v1/projects/[projectId]/dispatch-plans/[planId]/retry/route.ts @@ -0,0 +1,123 @@ +import { NextRequest, NextResponse } from "next/server"; +import { requireRequestSession } from "@/lib/boss-auth"; +import { appendProjectMessage, readState } from "@/lib/boss-data"; +import { queueGroupDispatchPlan } 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": + return "当前群聊里还没有可下发的真实线程,请先在群资料里重新添加线程后再试。"; + case "DISPATCH_TARGET_PROJECT_NOT_FOUND": + return "当前群聊里有失效的线程引用,请重新整理群成员后再试。"; + default: + return error ? `主 Agent 暂时无法重新生成推荐:${error}` : "主 Agent 暂时无法重新生成推荐,请稍后重试。"; + } +} + +export async function POST( + request: NextRequest, + context: { params: Promise<{ projectId: string; planId: string }> }, +) { + const session = await requireRequestSession(request); + if (!session) { + return NextResponse.json({ ok: false, message: "UNAUTHORIZED" }, { status: 401 }); + } + + const { projectId, planId } = await context.params; + try { + const state = await readState(); + const project = state.projects.find((item) => item.id === projectId); + if (!project) { + return NextResponse.json({ ok: false, message: "PROJECT_NOT_FOUND" }, { status: 404 }); + } + if (!project.isGroup) { + return NextResponse.json({ ok: false, message: "PROJECT_NOT_GROUP_CHAT" }, { status: 400 }); + } + + const plan = state.dispatchPlans.find((item) => item.planId === planId); + if (!plan || plan.groupProjectId !== projectId) { + return NextResponse.json({ ok: false, message: "DISPATCH_PLAN_NOT_FOUND" }, { status: 404 }); + } + if (plan.status !== "rejected") { + return NextResponse.json({ ok: false, message: "DISPATCH_PLAN_NOT_REJECTED" }, { status: 400 }); + } + + const pendingPlan = [...state.dispatchPlans] + .filter( + (item) => item.groupProjectId === projectId && item.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 requestMessage = project.messages.find((message) => message.id === plan.requestMessageId); + const requestText = requestMessage?.body?.trim() || plan.summary?.trim(); + if (!requestText) { + return NextResponse.json( + { ok: false, message: "DISPATCH_PLAN_REQUEST_TEXT_REQUIRED" }, + { status: 400 }, + ); + } + + const retryMessageId = `${plan.requestMessageId}:retry:${Date.now()}`; + const recommendation = await queueGroupDispatchPlan({ + groupProjectId: projectId, + requestMessageId: retryMessageId, + requestText, + requestedBy: session.account, + }); + + if (!recommendation.ok) { + await appendProjectMessage({ + projectId, + sender: "master", + senderLabel: "主 Agent", + body: dispatchFailureNotice(recommendation.error), + kind: "system_notice", + }); + } + + const nextState = await readState(); + const nextProject = nextState.projects.find((item) => item.id === projectId); + return NextResponse.json({ + ok: true, + dispatchRecommendation: recommendation, + dispatchPlan: recommendation.dispatchPlan, + collaborationGate: buildCollaborationGate(nextProject), + }); + } catch (error) { + return NextResponse.json( + { ok: false, message: error instanceof Error ? error.message : "UNKNOWN_ERROR" }, + { status: 400 }, + ); + } +} diff --git a/src/app/conversations/[projectId]/page.tsx b/src/app/conversations/[projectId]/page.tsx index 9635156..8289d04 100644 --- a/src/app/conversations/[projectId]/page.tsx +++ b/src/app/conversations/[projectId]/page.tsx @@ -12,7 +12,7 @@ import { } 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 { latestPendingDispatchPlan, latestRejectedDispatchPlan } from "@/lib/dispatch-plan-ui"; import { formatTimestampLabel, getProjectDetailView } from "@/lib/boss-projections"; export const dynamic = "force-dynamic"; @@ -29,6 +29,10 @@ export default async function ProjectChatPage({ const pendingDispatchPlan = detail?.project.isGroup ? latestPendingDispatchPlan(await listDispatchPlansByProject(projectId)) : null; + const rejectedDispatchPlan = + detail?.project.isGroup && !pendingDispatchPlan + ? latestRejectedDispatchPlan(await listDispatchPlansByProject(projectId)) + : null; if (!detail) notFound(); @@ -165,6 +169,18 @@ export default async function ProjectChatPage({ } : null } + initialRejectedDispatchPlan={ + rejectedDispatchPlan + ? { + planId: rejectedDispatchPlan.planId, + summary: rejectedDispatchPlan.summary, + targets: (rejectedDispatchPlan.targets ?? []).map((target) => ({ + projectId: target.projectId, + threadDisplayName: target.threadDisplayName, + })), + } + : null + } /> ); diff --git a/src/components/app-ui.tsx b/src/components/app-ui.tsx index 66636dc..c93e343 100644 --- a/src/components/app-ui.tsx +++ b/src/components/app-ui.tsx @@ -819,9 +819,11 @@ type PendingDispatchPlanState = { export function ChatComposer({ projectId, initialPendingDispatchPlan, + initialRejectedDispatchPlan, }: { projectId: string; initialPendingDispatchPlan?: PendingDispatchPlanState | null; + initialRejectedDispatchPlan?: PendingDispatchPlanState | null; }) { const router = useRouter(); const [value, setValue] = useState(""); @@ -830,12 +832,16 @@ export function ChatComposer({ const [loading, setLoading] = useState(false); const [localPendingDispatchPlan, setLocalPendingDispatchPlan] = useState(null); + const [localRejectedDispatchPlan, setLocalRejectedDispatchPlan] = + useState(null); const [dismissedPendingPlanId, setDismissedPendingPlanId] = useState(null); const pendingDispatchPlan = localPendingDispatchPlan ?? (initialPendingDispatchPlan && initialPendingDispatchPlan.planId !== dismissedPendingPlanId ? initialPendingDispatchPlan : null); + const rejectedDispatchPlan = + pendingDispatchPlan ? null : localRejectedDispatchPlan ?? initialRejectedDispatchPlan ?? null; async function confirmDispatchPlan() { if (!pendingDispatchPlan) return; @@ -863,12 +869,102 @@ export function ChatComposer({ } const executionCount = result.executions?.length ?? extractApprovedTargetProjectIds(pendingDispatchPlan).length; setLocalPendingDispatchPlan(null); + setLocalRejectedDispatchPlan(null); setDismissedPendingPlanId(pendingDispatchPlan.planId); setMessageTone("success"); setMessage(`已确认下发到 ${executionCount} 个线程。`); router.refresh(); } + async function retryDispatchPlan() { + if (!rejectedDispatchPlan) return; + setLoading(true); + const response = await fetch( + `/api/v1/projects/${projectId}/dispatch-plans/${rejectedDispatchPlan.planId}/retry`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + }, + ); + const result = (await response.json()) as { + ok: boolean; + dispatchPlan?: { + planId: string; + summary?: string; + targets?: Array<{ projectId: string; threadDisplayName: string }>; + } | null; + collaborationGate?: { + requiresMasterAgentApproval?: boolean; + }; + message?: string; + }; + setLoading(false); + if (!result.ok) { + setMessageTone("error"); + setMessage(result.message ?? "重新生成推荐失败,请稍后重试。"); + return; + } + setLocalRejectedDispatchPlan(null); + setLocalPendingDispatchPlan( + result.dispatchPlan + ? { + planId: result.dispatchPlan.planId, + summary: result.dispatchPlan.summary, + targets: result.dispatchPlan.targets ?? [], + } + : null, + ); + setDismissedPendingPlanId(null); + setMessageTone("success"); + setMessage( + result.collaborationGate?.requiresMasterAgentApproval + ? "主 Agent 已重新生成推荐,等待你确认下发。" + : "主 Agent 已重新生成推荐。", + ); + router.refresh(); + } + + async function rejectDispatchPlan() { + if (!pendingDispatchPlan) return; + setLoading(true); + const response = await fetch( + `/api/v1/projects/${projectId}/dispatch-plans/${pendingDispatchPlan.planId}/reject`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + }, + ); + const result = (await response.json()) as { + ok: boolean; + plan?: { + planId: string; + summary?: string; + targets?: Array<{ projectId: string; threadDisplayName: string }>; + } | null; + message?: string; + }; + setLoading(false); + if (!result.ok) { + setMessageTone("error"); + setMessage(result.message ?? "拒绝失败,请稍后重试。"); + return; + } + setLocalPendingDispatchPlan(null); + setLocalRejectedDispatchPlan( + result.plan + ? { + planId: result.plan.planId, + summary: result.plan.summary, + targets: result.plan.targets ?? pendingDispatchPlan.targets, + } + : pendingDispatchPlan, + ); + setDismissedPendingPlanId(pendingDispatchPlan.planId); + setMessageTone("success"); + setMessage("已拒绝主 Agent 推荐。"); + router.refresh(); + } + async function send(kind: "text" | "voice_intent" | "image_intent" | "video_intent") { setLoading(true); const response = await fetch(`/api/v1/projects/${projectId}/messages`, { @@ -907,6 +1003,7 @@ export function ChatComposer({ summary: result.dispatchPlan.summary, targets: result.dispatchPlan.targets ?? [], }); + setLocalRejectedDispatchPlan(null); setDismissedPendingPlanId(null); setMessage( result.collaborationGate?.requiresMasterAgentApproval @@ -997,6 +1094,14 @@ export function ChatComposer({ > 确认下发 + + + + ) : null} ); } diff --git a/src/components/device-import-draft-manager.tsx b/src/components/device-import-draft-manager.tsx index ba17aec..8245fda 100644 --- a/src/components/device-import-draft-manager.tsx +++ b/src/components/device-import-draft-manager.tsx @@ -192,6 +192,37 @@ export function DeviceImportDraftManager({ ); } + 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); + 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 { @@ -209,6 +240,9 @@ export function DeviceImportDraftManager({ setFeedback({ tone: "error", text: selectResult.message ?? "勾选保存失败" }); return; } + setDraft(selectResult.draft ?? draft); + setResolution(null); + setSelectedCandidateIds(selectResult.draft?.selectedCandidateIds ?? selectedCandidateIds); const reviewResponse = await fetch(`/api/v1/devices/${deviceId}/import-draft/review`, { method: "POST", @@ -222,7 +256,7 @@ export function DeviceImportDraftManager({ resolution?: DeviceImportResolution; }; if (!reviewResult.ok) { - setFeedback({ tone: "error", text: reviewResult.message ?? "导入建议生成失败" }); + setFeedback({ tone: "error", text: reviewResult.message ?? "导入建议生成失败,已保留当前勾选。" }); return; } setDraft(reviewResult.draft ?? selectResult.draft ?? null); @@ -290,7 +324,7 @@ export function DeviceImportDraftManager({
导入 Codex 项目
- {deviceName ?? deviceId} 完成首次 heartbeat 后,这里会出现可导入项目和线程。 + {deviceName ?? deviceId} 完成首次 heartbeat 后,这里会出现可导入项目和线程;如果暂时为空,先刷新等待下一次发现。
+