diff --git a/app/api/canvas/clawd-chat/cancel/route.ts b/app/api/canvas/clawd-chat/cancel/route.ts new file mode 100644 index 000000000..138a372e1 --- /dev/null +++ b/app/api/canvas/clawd-chat/cancel/route.ts @@ -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 }); +} diff --git a/app/api/canvas/clawd-chat/route.ts b/app/api/canvas/clawd-chat/route.ts index 354dd11d7..01479493a 100644 --- a/app/api/canvas/clawd-chat/route.ts +++ b/app/api/canvas/clawd-chat/route.ts @@ -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 > | 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, diff --git a/components/canvas/CanvasChatPanel.tsx b/components/canvas/CanvasChatPanel.tsx index 3100f7a9b..a20bc46e5 100644 --- a/components/canvas/CanvasChatPanel.tsx +++ b/components/canvas/CanvasChatPanel.tsx @@ -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 (
@@ -1618,6 +1632,19 @@ function CanvasAssistantTypingPlaceholder({ > {label} + {onCancel ? ( + + ) : null}
@@ -2259,6 +2286,10 @@ export function CanvasChatPanel({ useRef(null); const recentClawdSubmissionRef = useRef(null); + const clawdAbortControllerRef = useRef(null); + const pendingClawdRequestRestoreRef = + useRef(null); + const canceledClawdMessageIdsRef = useRef>(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) => { if (isSubmitting || !isClawdMode || !clawdEditablePresetPlaceholder) return; @@ -13878,7 +14022,12 @@ export function CanvasChatPanel({ ) : null} {isClawdMode && isClawdSubmitting ? ( - + ) : null}
@@ -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 ? ( + <> + + {isWorkspacePresentation ? 停止 : null} + + ) : isSubmitting ? ( <> {isWorkspacePresentation ? 生成中 : null} diff --git a/lib/openclaw-chat/cancel-registry.ts b/lib/openclaw-chat/cancel-registry.ts new file mode 100644 index 000000000..8896d37be --- /dev/null +++ b/lib/openclaw-chat/cancel-registry.ts @@ -0,0 +1,76 @@ +const CANCELED_REQUEST_TTL_MS = 10 * 60 * 1000; + +type ActiveClawdRequest = { + controller: AbortController; + expiresAt: number; +}; + +const activeRequests = new Map(); +const canceledRequests = new Map(); + +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); +}