Add thread execution conflict guards to chat flows
This commit is contained in:
@@ -21,6 +21,15 @@ import {
|
||||
summarizeDispatchPlanCompact,
|
||||
summarizeDispatchPlanLightTitle,
|
||||
} from "@/lib/dispatch-plan-ui";
|
||||
import type {
|
||||
ThreadConversationExecutionConflict,
|
||||
ThreadConversationExecutionConflictAction,
|
||||
} from "@/lib/thread-execution-conflict";
|
||||
import {
|
||||
describeThreadConversationExecutionConflict,
|
||||
labelForThreadConversationExecutionConflictDecision,
|
||||
summarizeThreadConversationExecutionDecisionResult,
|
||||
} from "@/lib/thread-execution-conflict-ui";
|
||||
import type {
|
||||
Device,
|
||||
DeviceEnrollment,
|
||||
@@ -1195,6 +1204,7 @@ export function ChatComposer({
|
||||
initialLightDispatchReminderEnabled?: boolean;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
type ComposerMessageKind = "text" | "voice_intent" | "image_intent" | "video_intent";
|
||||
const [value, setValue] = useState("");
|
||||
const [message, setMessage] = useState("");
|
||||
const [messageTone, setMessageTone] = useState<"success" | "error">("success");
|
||||
@@ -1207,6 +1217,11 @@ export function ChatComposer({
|
||||
const [lightDispatchReminderEnabled, setLightDispatchReminderEnabled] = useState(
|
||||
initialLightDispatchReminderEnabled,
|
||||
);
|
||||
const [threadExecutionConflict, setThreadExecutionConflict] = useState<{
|
||||
conflict: ThreadConversationExecutionConflict;
|
||||
draftBody: string;
|
||||
kind: ComposerMessageKind;
|
||||
} | null>(null);
|
||||
const pendingDispatchPlan =
|
||||
localPendingDispatchPlan ??
|
||||
(initialPendingDispatchPlan && initialPendingDispatchPlan.planId !== dismissedPendingPlanId
|
||||
@@ -1214,6 +1229,9 @@ export function ChatComposer({
|
||||
: null);
|
||||
const rejectedDispatchPlan =
|
||||
pendingDispatchPlan ? null : localRejectedDispatchPlan ?? initialRejectedDispatchPlan ?? null;
|
||||
const threadExecutionConflictDescription = threadExecutionConflict
|
||||
? describeThreadConversationExecutionConflict(threadExecutionConflict.conflict)
|
||||
: null;
|
||||
|
||||
async function confirmDispatchPlan(rememberLightReminder = false) {
|
||||
if (!pendingDispatchPlan) return;
|
||||
@@ -1352,16 +1370,25 @@ export function ChatComposer({
|
||||
router.refresh();
|
||||
}
|
||||
|
||||
async function send(kind: "text" | "voice_intent" | "image_intent" | "video_intent") {
|
||||
async function send(
|
||||
kind: ComposerMessageKind,
|
||||
options?: {
|
||||
draftBody?: string;
|
||||
},
|
||||
) {
|
||||
const draftBody = kind === "text" ? (options?.draftBody ?? value).trim() : "";
|
||||
if (kind === "text" && !draftBody) {
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
const response = await fetch(`/api/v1/projects/${projectId}/messages`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ body: kind === "text" ? value : undefined, kind }),
|
||||
body: JSON.stringify({ body: kind === "text" ? draftBody : undefined, kind }),
|
||||
});
|
||||
const result = (await response.json()) as {
|
||||
ok: boolean;
|
||||
message?: { body: string };
|
||||
message?: { body: string } | string;
|
||||
dispatchPlan?: {
|
||||
planId: string;
|
||||
summary?: string;
|
||||
@@ -1371,17 +1398,30 @@ export function ChatComposer({
|
||||
requiresMasterAgentApproval?: boolean;
|
||||
lightDispatchReminderEnabled?: boolean;
|
||||
};
|
||||
code?: string;
|
||||
executionConflict?: ThreadConversationExecutionConflict;
|
||||
messageText?: string;
|
||||
};
|
||||
setLoading(false);
|
||||
if (!result.ok && response.status === 409 && result.code === "THREAD_EXECUTION_CONFLICT" && result.executionConflict) {
|
||||
setThreadExecutionConflict({
|
||||
conflict: result.executionConflict,
|
||||
draftBody,
|
||||
kind,
|
||||
});
|
||||
setMessageTone("error");
|
||||
setMessage(typeof result.message === "string" ? result.message : "当前线程命中了 GUI / CLI 冲突保护。");
|
||||
return;
|
||||
}
|
||||
if (result.ok) {
|
||||
setThreadExecutionConflict(null);
|
||||
void sendAppLog({
|
||||
deviceId: boundDeviceIdFromDom(),
|
||||
projectId,
|
||||
level: "info",
|
||||
category: "chat.message_sent",
|
||||
message:
|
||||
kind === "text" ? `已发送文本消息:${value.trim() || "空文本"}` : `已发送 ${kind} 意图消息。`,
|
||||
kind === "text" ? `已发送文本消息:${draftBody || "空文本"}` : `已发送 ${kind} 意图消息。`,
|
||||
mirrorToMaster: false,
|
||||
});
|
||||
setValue("");
|
||||
@@ -1419,7 +1459,44 @@ export function ChatComposer({
|
||||
mirrorToMaster: true,
|
||||
});
|
||||
setMessageTone("error");
|
||||
setMessage("消息发送失败,请重试。");
|
||||
setMessage(typeof result.message === "string" ? result.message : "消息发送失败,请重试。");
|
||||
}
|
||||
|
||||
async function handleThreadExecutionConflictDecision(
|
||||
decision: ThreadConversationExecutionConflictAction,
|
||||
) {
|
||||
if (!threadExecutionConflict) {
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
const response = await fetch(`/api/v1/devices/${threadExecutionConflict.conflict.deviceId}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
projectId: threadExecutionConflict.conflict.projectId,
|
||||
folderKey: threadExecutionConflict.conflict.folderKey ?? null,
|
||||
conflictDecision: decision,
|
||||
}),
|
||||
});
|
||||
const result = (await response.json()) as {
|
||||
ok: boolean;
|
||||
message?: string;
|
||||
};
|
||||
setLoading(false);
|
||||
if (!result.ok) {
|
||||
setMessageTone("error");
|
||||
setMessage(result.message ?? "冲突放行设置失败,请重试。");
|
||||
return;
|
||||
}
|
||||
|
||||
const pendingDraft = threadExecutionConflict;
|
||||
setThreadExecutionConflict(null);
|
||||
setMessageTone(decision === "forbid" ? "error" : "success");
|
||||
setMessage(summarizeThreadConversationExecutionDecisionResult(decision));
|
||||
if (decision === "forbid") {
|
||||
return;
|
||||
}
|
||||
await send(pendingDraft.kind, { draftBody: pendingDraft.draftBody });
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -1477,6 +1554,40 @@ export function ChatComposer({
|
||||
{dispatchPlanRecoveryHint}
|
||||
</div>
|
||||
) : null}
|
||||
{threadExecutionConflict && threadExecutionConflictDescription ? (
|
||||
<div className="mt-3 rounded-2xl border border-[#F3D19C] bg-[#FFF7E6] px-4 py-4 text-[12px] leading-6 text-[#8D5D00]">
|
||||
<div className="text-[14px] font-semibold text-[#111111]">
|
||||
{threadExecutionConflictDescription.title}
|
||||
</div>
|
||||
<div className="mt-2">{threadExecutionConflictDescription.summary}</div>
|
||||
<div className="mt-2 text-[12px] text-[#8C8C8C]">
|
||||
设备:{threadExecutionConflict.conflict.deviceName}
|
||||
{" · "}
|
||||
默认模式:{threadExecutionConflict.conflict.preferredExecutionMode === "gui" ? "GUI" : "CLI"}
|
||||
{threadExecutionConflict.conflict.folderKey ? ` · ${threadExecutionConflict.conflict.folderKey}` : ""}
|
||||
</div>
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
{threadExecutionConflict.conflict.actions.map((action) => (
|
||||
<button
|
||||
key={action}
|
||||
type="button"
|
||||
disabled={loading}
|
||||
onClick={() => void handleThreadExecutionConflictDecision(action)}
|
||||
className={clsx(
|
||||
"rounded-full px-4 py-2 text-[13px] font-semibold disabled:opacity-60",
|
||||
action === "allow_once"
|
||||
? "bg-[#07C160] text-white"
|
||||
: action === "allow_always"
|
||||
? "bg-[#2563EB] text-white"
|
||||
: "border border-[#F0B5B5] text-[#CF1322]",
|
||||
)}
|
||||
>
|
||||
{labelForThreadConversationExecutionConflictDecision(action)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{pendingDispatchPlan ? (
|
||||
<div className="mt-3 rounded-2xl border border-[#E5E5EA] bg-[#F7F8FA] px-4 py-4 text-[12px] leading-6 text-[#57606A]">
|
||||
<div className="text-[14px] font-semibold text-[#111111]">
|
||||
|
||||
Reference in New Issue
Block a user