feat: allow canceling claw submissions

This commit is contained in:
134 2026-07-08 22:14:20 +08:00
parent fd5b483bad
commit e1f514536b
4 changed files with 345 additions and 11 deletions

View File

@ -0,0 +1,40 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import { auth } from "@/app/(auth)/auth";
import { getChatById } from "@/lib/db/queries";
import {
buildClawdCancelKey,
cancelClawdRequest,
} from "@/lib/openclaw-chat/cancel-registry";
const cancelSchema = z.object({
id: z.string().uuid(),
userMessageId: z.string().uuid(),
});
export async function POST(request: Request) {
const session = await auth().catch(() => null);
if (!session?.user?.id) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const parsed = cancelSchema.safeParse(await request.json().catch(() => null));
if (!parsed.success) {
return NextResponse.json({ error: "请求参数不正确" }, { status: 400 });
}
const existingChat = await getChatById({ id: parsed.data.id }).catch(() => null);
if (existingChat && existingChat.userId !== session.user.id) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
const active = cancelClawdRequest(
buildClawdCancelKey({
userId: session.user.id,
chatId: parsed.data.id,
userMessageId: parsed.data.userMessageId,
})
);
return NextResponse.json({ canceled: true, active });
}

View File

@ -13,6 +13,14 @@ import {
normalizeOpenClawChatConfig,
OPENCLAW_CHAT_CONFIG_KEY,
} from "@/lib/openclaw-chat/config";
import {
buildClawdCancelKey,
cancelClawdRequest,
clearClawdRequestCancellation,
isClawdRequestCanceled,
registerActiveClawdRequest,
unregisterActiveClawdRequest,
} from "@/lib/openclaw-chat/cancel-registry";
import {
buildOpenClawDisplayMessage,
buildOpenClawMessageWithSpreadsheetMarkdown,
@ -141,6 +149,10 @@ function getMetadataString(
return typeof value === "string" ? value.trim() : "";
}
function createCanceledResponse() {
return NextResponse.json({ canceled: true }, { status: 499 });
}
export async function POST(request: Request) {
const session = await auth().catch(() => null);
@ -172,6 +184,16 @@ export async function POST(request: Request) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
const userMessageId = parsed.userMessageId || generateUUID();
const cancelKey = buildClawdCancelKey({
userId: session.user.id,
chatId: parsed.id,
userMessageId,
});
if (isClawdRequestCanceled(cancelKey)) {
return createCanceledResponse();
}
let spreadsheetMarkdownResult: Awaited<
ReturnType<typeof buildOpenClawSpreadsheetMarkdown>
> | null = null;
@ -200,6 +222,10 @@ export async function POST(request: Request) {
spreadsheetMarkdown: spreadsheetMarkdownResult?.markdown || "",
});
if (isClawdRequestCanceled(cancelKey)) {
return createCanceledResponse();
}
if (!existingChat) {
await saveChat({
id: parsed.id,
@ -209,7 +235,6 @@ export async function POST(request: Request) {
});
}
const userMessageId = parsed.userMessageId || generateUUID();
const assistantMessageId = generateUUID();
const conversationId = createConversationId(session.user.id, parsed.id);
const userProfile = await getUserById({ id: session.user.id }).catch(() => null);
@ -226,9 +251,18 @@ export async function POST(request: Request) {
let upstreamPayload: unknown = null;
let upstreamStatus = 502;
if (isClawdRequestCanceled(cancelKey)) {
return createCanceledResponse();
}
try {
const controller = new AbortController();
registerActiveClawdRequest(cancelKey, controller, config.timeoutMs);
const timeout = setTimeout(() => controller.abort(), config.timeoutMs);
const handleRequestAbort = () => {
cancelClawdRequest(cancelKey);
};
request.signal.addEventListener("abort", handleRequestAbort, { once: true });
const response = await fetch(endpoint, {
method: "POST",
headers: {
@ -266,7 +300,11 @@ export async function POST(request: Request) {
},
}),
signal: controller.signal,
}).finally(() => clearTimeout(timeout));
}).finally(() => {
clearTimeout(timeout);
request.signal.removeEventListener("abort", handleRequestAbort);
unregisterActiveClawdRequest(cancelKey);
});
upstreamStatus = response.status;
upstreamPayload = await response.json().catch(() => null);
@ -287,8 +325,19 @@ export async function POST(request: Request) {
);
}
} catch (error) {
const isAbortError =
error instanceof Error && error.name === "AbortError";
unregisterActiveClawdRequest(cancelKey);
const errorName =
error && typeof error === "object" && "name" in error
? String((error as { name?: unknown }).name || "")
: "";
const isAbortError = errorName === "AbortError";
if (isClawdRequestCanceled(cancelKey)) {
console.info("[ClawdChat API] OpenClaw request canceled:", {
chatId: parsed.id,
userMessageId,
});
return createCanceledResponse();
}
console.error("[ClawdChat API] OpenClaw request failed:", error);
return NextResponse.json(
{
@ -311,6 +360,10 @@ export async function POST(request: Request) {
);
}
if (isClawdRequestCanceled(cancelKey)) {
return createCanceledResponse();
}
const existingMessages = await getMessagesByChatId({ id: parsed.id }).catch(
() => []
);
@ -366,6 +419,7 @@ export async function POST(request: Request) {
};
await saveMessages({ messages: [userMessage, assistantMessage] });
clearClawdRequestCancellation(cancelKey);
return NextResponse.json({
conversationId,

View File

@ -25,7 +25,7 @@ import {
type UiSurfaceSnapshot,
} from "@/lib/ui-surface-protocol";
import useSWR, { useSWRConfig } from "swr";
import { Loader2, ImagePlus, X, Plus, History, LayoutGrid, ArrowRightToLine, RefreshCw, Globe, ArrowUp, Video, Sparkles, Check, ChevronDown, ChevronUp, AlertCircle, CheckCircle2, Brain, ThumbsUp, ThumbsDown, Eye, Trash2, Download, MessageCircle, Clapperboard, Bot, FileSpreadsheet, Paperclip, Copy } from "lucide-react";
import { Loader2, ImagePlus, X, Plus, History, LayoutGrid, ArrowRightToLine, RefreshCw, Globe, ArrowUp, Video, Sparkles, Check, ChevronDown, ChevronUp, AlertCircle, CheckCircle2, Brain, ThumbsUp, ThumbsDown, Eye, Trash2, Download, MessageCircle, Clapperboard, Bot, FileSpreadsheet, Paperclip, Copy, Square } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Collapsible,
@ -458,6 +458,16 @@ type ClawdComposerSubmissionSnapshot = {
submittedTexts: string[];
};
type PendingClawdRequestRestoreSnapshot = {
chatId: string;
userMessageId: string;
textContent: string;
tableFiles: File[];
attachments: PickedObjectAttachment[];
skillTag: CanvasSkillTag | null;
shouldRestoreSkillTag: boolean;
};
function isClawdSubmittedComposerText(
snapshot: ClawdComposerSubmissionSnapshot,
value: string
@ -1600,9 +1610,13 @@ function OpenClawQuickActions({
function CanvasAssistantTypingPlaceholder({
hint,
label = "分析中",
onCancel,
cancelLabel = "停止",
}: {
hint?: string | null;
label?: string;
onCancel?: () => void;
cancelLabel?: string;
}) {
return (
<div className="flex w-full justify-start">
@ -1618,6 +1632,19 @@ function CanvasAssistantTypingPlaceholder({
>
<Loader2 className="h-4 w-4 animate-spin" />
<span>{label}</span>
{onCancel ? (
<button
type="button"
className="-mr-1 ml-1 rounded-full px-2 py-0.5 text-[12px] font-semibold text-[#5d6675] transition-colors hover:bg-black/5 hover:text-[#242a33] dark:text-zinc-300 dark:hover:bg-white/10 dark:hover:text-white"
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
onCancel();
}}
>
{cancelLabel}
</button>
) : null}
</div>
</div>
</div>
@ -2259,6 +2286,10 @@ export function CanvasChatPanel({
useRef<ClawdComposerSubmissionSnapshot | null>(null);
const recentClawdSubmissionRef =
useRef<ClawdComposerSubmissionSnapshot | null>(null);
const clawdAbortControllerRef = useRef<AbortController | null>(null);
const pendingClawdRequestRestoreRef =
useRef<PendingClawdRequestRestoreSnapshot | null>(null);
const canceledClawdMessageIdsRef = useRef<Set<string>>(new Set());
const replaceChatSessionQuery = useCallback(
(sessionId: string) => {
@ -7120,6 +7151,76 @@ export function CanvasChatPanel({
},
});
const restorePendingClawdRequestToComposer = useCallback(
(snapshot: PendingClawdRequestRestoreSnapshot) => {
setMessages((previousMessages) =>
previousMessages.filter((message) => message.id !== snapshot.userMessageId)
);
setClawdTableFiles(snapshot.tableFiles);
setAttachments(snapshot.attachments);
if (snapshot.skillTag && snapshot.shouldRestoreSkillTag) {
setSessionClawdSkillTag(snapshot.chatId, snapshot.skillTag.id, {
promptApplied: false,
});
setSelectedSkillTagId(snapshot.skillTag.id);
pendingSelectedClawdSkillTagRef.current = snapshot.skillTag;
}
composerUserEditedAtRef.current = Date.now();
inputRef.current = snapshot.textContent;
setInput(snapshot.textContent);
const editableDiv = textareaRef.current as HTMLDivElement | null;
if (editableDiv) {
editableDiv.innerHTML = "";
editableDiv.textContent = snapshot.textContent;
window.setTimeout(() => {
snapshot.attachments.forEach((attachment, index) => {
if (!attachment.url) return;
insertImageTagInEditor(
attachment.url,
normalizeChatAttachmentName(attachment.name),
index
);
});
editableDiv.focus();
}, 0);
}
},
[insertImageTagInEditor, setMessages, setSessionClawdSkillTag]
);
const handleCancelClawdSubmission = useCallback(() => {
const snapshot = pendingClawdRequestRestoreRef.current;
if (!snapshot) return;
canceledClawdMessageIdsRef.current.add(snapshot.userMessageId);
pendingClawdRequestRestoreRef.current = null;
pendingClawdSubmissionRef.current = null;
recentClawdSubmissionRef.current = null;
writeStoredClawdComposerSubmissionSnapshot(null);
void fetch("/api/canvas/clawd-chat/cancel", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
id: snapshot.chatId,
userMessageId: snapshot.userMessageId,
}),
}).catch((error) => {
console.warn("[CanvasChat] Claw cancel request failed:", error);
});
clawdAbortControllerRef.current?.abort();
clawdAbortControllerRef.current = null;
setIsClawdSubmitting(false);
setClawdProcessingHint(null);
releaseSendGuard(snapshot.chatId);
restorePendingClawdRequestToComposer(snapshot);
toast.message("已停止处理,可以重新编辑后再发送");
}, [releaseSendGuard, restorePendingClawdRequestToComposer]);
useEffect(() => {
activeMessagesRef.current = messages;
}, [messages]);
@ -10731,6 +10832,19 @@ export function CanvasChatPanel({
pendingClawdSubmissionRef.current = clawdSubmissionSnapshot;
recentClawdSubmissionRef.current = clawdSubmissionSnapshot;
writeStoredClawdComposerSubmissionSnapshot(clawdSubmissionSnapshot);
const clawdAbortController = new AbortController();
const clawdRequestRestoreSnapshot: PendingClawdRequestRestoreSnapshot = {
chatId: activeChatId,
userMessageId,
textContent,
tableFiles: tableFilesSnapshot,
attachments: attachmentsSnapshot,
skillTag: clawdSkillTagForRequest || null,
shouldRestoreSkillTag: hasPendingClawdSkillTagPrompt,
};
canceledClawdMessageIdsRef.current.delete(userMessageId);
clawdAbortControllerRef.current = clawdAbortController;
pendingClawdRequestRestoreRef.current = clawdRequestRestoreSnapshot;
setClawdProcessingHint(nextProcessingHint);
setIsClawdSubmitting(true);
setMessages((previousMessages) =>
@ -10773,8 +10887,13 @@ export function CanvasChatPanel({
const response = await fetch("/api/canvas/clawd-chat", {
method: "POST",
body: formData,
signal: clawdAbortController.signal,
});
const responseData = await response.json().catch(() => null);
if (response.status === 499 || responseData?.canceled === true) {
canceledClawdMessageIdsRef.current.add(userMessageId);
throw new DOMException("Claw request canceled", "AbortError");
}
if (!response.ok) {
console.error("[CanvasChat] Claw API returned error:", {
status: response.status,
@ -10824,6 +10943,17 @@ export function CanvasChatPanel({
void mutateClawdHistory();
hasHydratedHistoryRef.current = true;
} catch (error) {
const wasCanceled =
canceledClawdMessageIdsRef.current.has(userMessageId) ||
(error instanceof Error && error.name === "AbortError");
if (wasCanceled) {
if (pendingClawdRequestRestoreRef.current === clawdRequestRestoreSnapshot) {
pendingClawdRequestRestoreRef.current = null;
restorePendingClawdRequestToComposer(clawdRequestRestoreSnapshot);
toast.message("已停止处理,可以重新编辑后再发送");
}
return;
}
console.error("[CanvasChat] Claw request failed:", error);
pendingClawdSubmissionRef.current = null;
if (recentClawdSubmissionRef.current === clawdSubmissionSnapshot) {
@ -10865,13 +10995,25 @@ export function CanvasChatPanel({
}
} finally {
pendingClawdSubmissionRef.current = null;
if (pendingClawdRequestRestoreRef.current === clawdRequestRestoreSnapshot) {
pendingClawdRequestRestoreRef.current = null;
}
if (clawdAbortControllerRef.current === clawdAbortController) {
clawdAbortControllerRef.current = null;
}
const wasCanceled = canceledClawdMessageIdsRef.current.delete(userMessageId);
if (recentClawdSubmissionRef.current === clawdSubmissionSnapshot) {
if (wasCanceled) {
recentClawdSubmissionRef.current = null;
writeStoredClawdComposerSubmissionSnapshot(null);
} else {
const refreshedSubmissionSnapshot = {
...clawdSubmissionSnapshot,
expiresAt: Date.now() + CLAWD_SUBMITTED_COMPOSER_RECOVERY_TTL_MS,
};
recentClawdSubmissionRef.current = refreshedSubmissionSnapshot;
writeStoredClawdComposerSubmissionSnapshot(refreshedSubmissionSnapshot);
}
}
setIsClawdSubmitting(false);
setClawdProcessingHint(null);
@ -11244,6 +11386,7 @@ export function CanvasChatPanel({
markSessionClawdSkillTagPromptApplied,
updateChatSessions,
buildHistoryMessagesUrl,
restorePendingClawdRequestToComposer,
workflowContext,
messages,
]);
@ -12092,6 +12235,7 @@ export function CanvasChatPanel({
isClawdMode && activeClawdSkillTag
? activeClawdSkillTag.placeholder?.trim() || ""
: "";
const isClawdStopAvailable = isClawdMode && isClawdSubmitting;
const handleClawdPresetPlaceholderDoubleClick = useCallback(
(event: ReactMouseEvent<HTMLDivElement>) => {
if (isSubmitting || !isClawdMode || !clawdEditablePresetPlaceholder) return;
@ -13878,7 +14022,12 @@ export function CanvasChatPanel({
) : null}
{isClawdMode && isClawdSubmitting ? (
<CanvasAssistantTypingPlaceholder hint={clawdProcessingHint} label="分析中" />
<CanvasAssistantTypingPlaceholder
cancelLabel="停止并编辑"
hint={clawdProcessingHint}
label="分析中"
onCancel={handleCancelClawdSubmission}
/>
) : null}
<div ref={messagesEndRef} />
@ -15756,7 +15905,9 @@ export function CanvasChatPanel({
isWorkspacePresentation
? "h-10 rounded-full px-4 transition-all duration-200 flex-shrink-0"
: "h-8 w-8 rounded-full transition-all duration-200 flex-shrink-0",
isVideoToolbarDark
isClawdStopAvailable
? "bg-rose-500 text-white shadow-sm hover:bg-rose-600 dark:bg-rose-500 dark:text-white dark:hover:bg-rose-400"
: isVideoToolbarDark
? canSend
? "border border-[#e7ff1a]/20 bg-[#e7ff1a] text-zinc-950 shadow-sm hover:bg-[#dff40f]"
: "border border-white/10 bg-[#242529] text-zinc-500 hover:bg-[#2c2d33]"
@ -15768,15 +15919,28 @@ export function CanvasChatPanel({
? "bg-[#eef0f3] text-[#20242a] shadow-sm ring-1 ring-inset ring-[#d5dae2] hover:bg-[#e4e7ec] dark:bg-white dark:text-zinc-950 dark:ring-white/15 dark:hover:bg-zinc-100"
: "bg-[#f1f1f2] text-[#b6bac2] hover:bg-[#ececee] dark:bg-[#242529] dark:text-[#777d88] dark:hover:bg-[#2c2d33]"
)}
onClick={() => void handleSend()}
disabled={isSubmitting || !canSend}
onClick={() => {
if (isClawdStopAvailable) {
handleCancelClawdSubmission();
return;
}
void handleSend();
}}
disabled={isClawdStopAvailable ? false : isSubmitting || !canSend}
title={
isWorkspacePresentation || isDetailGenerationMode
isClawdStopAvailable
? "停止处理并重新编辑"
: isWorkspacePresentation || isDetailGenerationMode
? getToolbarItem("chatStartGeneration").label
: getToolbarItem("chatSend").label
}
>
{isSubmitting ? (
{isClawdStopAvailable ? (
<>
<Square className="h-3.5 w-3.5 fill-current" />
{isWorkspacePresentation ? <span className="ml-2">停止</span> : null}
</>
) : isSubmitting ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
{isWorkspacePresentation ? <span className="ml-2">生成中</span> : null}

View File

@ -0,0 +1,76 @@
const CANCELED_REQUEST_TTL_MS = 10 * 60 * 1000;
type ActiveClawdRequest = {
controller: AbortController;
expiresAt: number;
};
const activeRequests = new Map<string, ActiveClawdRequest>();
const canceledRequests = new Map<string, number>();
function cleanupExpiredClawdRequests(now = Date.now()) {
for (const [key, request] of activeRequests.entries()) {
if (request.expiresAt <= now) {
activeRequests.delete(key);
}
}
for (const [key, expiresAt] of canceledRequests.entries()) {
if (expiresAt <= now) {
canceledRequests.delete(key);
}
}
}
export function buildClawdCancelKey({
userId,
chatId,
userMessageId,
}: {
userId: string;
chatId: string;
userMessageId: string;
}) {
return `${userId}:${chatId}:${userMessageId}`;
}
export function registerActiveClawdRequest(
key: string,
controller: AbortController,
timeoutMs: number
) {
cleanupExpiredClawdRequests();
activeRequests.set(key, {
controller,
expiresAt: Date.now() + Math.max(timeoutMs, CANCELED_REQUEST_TTL_MS),
});
}
export function unregisterActiveClawdRequest(key: string) {
activeRequests.delete(key);
}
export function cancelClawdRequest(key: string) {
cleanupExpiredClawdRequests();
canceledRequests.set(key, Date.now() + CANCELED_REQUEST_TTL_MS);
const activeRequest = activeRequests.get(key);
if (activeRequest && !activeRequest.controller.signal.aborted) {
activeRequest.controller.abort();
}
return Boolean(activeRequest);
}
export function isClawdRequestCanceled(key: string) {
cleanupExpiredClawdRequests();
const expiresAt = canceledRequests.get(key);
if (!expiresAt) return false;
if (expiresAt <= Date.now()) {
canceledRequests.delete(key);
return false;
}
return true;
}
export function clearClawdRequestCancellation(key: string) {
canceledRequests.delete(key);
}