feat: allow canceling claw submissions
This commit is contained in:
parent
b49519ec56
commit
ea3ee3a5d7
40
app/api/canvas/clawd-chat/cancel/route.ts
Normal file
40
app/api/canvas/clawd-chat/cancel/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
@ -13,6 +13,14 @@ import {
|
|||||||
normalizeOpenClawChatConfig,
|
normalizeOpenClawChatConfig,
|
||||||
OPENCLAW_CHAT_CONFIG_KEY,
|
OPENCLAW_CHAT_CONFIG_KEY,
|
||||||
} from "@/lib/openclaw-chat/config";
|
} from "@/lib/openclaw-chat/config";
|
||||||
|
import {
|
||||||
|
buildClawdCancelKey,
|
||||||
|
cancelClawdRequest,
|
||||||
|
clearClawdRequestCancellation,
|
||||||
|
isClawdRequestCanceled,
|
||||||
|
registerActiveClawdRequest,
|
||||||
|
unregisterActiveClawdRequest,
|
||||||
|
} from "@/lib/openclaw-chat/cancel-registry";
|
||||||
import {
|
import {
|
||||||
buildOpenClawDisplayMessage,
|
buildOpenClawDisplayMessage,
|
||||||
buildOpenClawMessageWithSpreadsheetMarkdown,
|
buildOpenClawMessageWithSpreadsheetMarkdown,
|
||||||
@ -141,6 +149,10 @@ function getMetadataString(
|
|||||||
return typeof value === "string" ? value.trim() : "";
|
return typeof value === "string" ? value.trim() : "";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function createCanceledResponse() {
|
||||||
|
return NextResponse.json({ canceled: true }, { status: 499 });
|
||||||
|
}
|
||||||
|
|
||||||
export async function POST(request: Request) {
|
export async function POST(request: Request) {
|
||||||
const session = await auth().catch(() => null);
|
const session = await auth().catch(() => null);
|
||||||
|
|
||||||
@ -172,6 +184,16 @@ export async function POST(request: Request) {
|
|||||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
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<
|
let spreadsheetMarkdownResult: Awaited<
|
||||||
ReturnType<typeof buildOpenClawSpreadsheetMarkdown>
|
ReturnType<typeof buildOpenClawSpreadsheetMarkdown>
|
||||||
> | null = null;
|
> | null = null;
|
||||||
@ -200,6 +222,10 @@ export async function POST(request: Request) {
|
|||||||
spreadsheetMarkdown: spreadsheetMarkdownResult?.markdown || "",
|
spreadsheetMarkdown: spreadsheetMarkdownResult?.markdown || "",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (isClawdRequestCanceled(cancelKey)) {
|
||||||
|
return createCanceledResponse();
|
||||||
|
}
|
||||||
|
|
||||||
if (!existingChat) {
|
if (!existingChat) {
|
||||||
await saveChat({
|
await saveChat({
|
||||||
id: parsed.id,
|
id: parsed.id,
|
||||||
@ -209,7 +235,6 @@ export async function POST(request: Request) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const userMessageId = parsed.userMessageId || generateUUID();
|
|
||||||
const assistantMessageId = generateUUID();
|
const assistantMessageId = generateUUID();
|
||||||
const conversationId = createConversationId(session.user.id, parsed.id);
|
const conversationId = createConversationId(session.user.id, parsed.id);
|
||||||
const userProfile = await getUserById({ id: session.user.id }).catch(() => null);
|
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 upstreamPayload: unknown = null;
|
||||||
let upstreamStatus = 502;
|
let upstreamStatus = 502;
|
||||||
|
|
||||||
|
if (isClawdRequestCanceled(cancelKey)) {
|
||||||
|
return createCanceledResponse();
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
|
registerActiveClawdRequest(cancelKey, controller, config.timeoutMs);
|
||||||
const timeout = setTimeout(() => controller.abort(), 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, {
|
const response = await fetch(endpoint, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
@ -266,7 +300,11 @@ export async function POST(request: Request) {
|
|||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
signal: controller.signal,
|
signal: controller.signal,
|
||||||
}).finally(() => clearTimeout(timeout));
|
}).finally(() => {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
request.signal.removeEventListener("abort", handleRequestAbort);
|
||||||
|
unregisterActiveClawdRequest(cancelKey);
|
||||||
|
});
|
||||||
|
|
||||||
upstreamStatus = response.status;
|
upstreamStatus = response.status;
|
||||||
upstreamPayload = await response.json().catch(() => null);
|
upstreamPayload = await response.json().catch(() => null);
|
||||||
@ -287,8 +325,19 @@ export async function POST(request: Request) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const isAbortError =
|
unregisterActiveClawdRequest(cancelKey);
|
||||||
error instanceof Error && error.name === "AbortError";
|
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);
|
console.error("[ClawdChat API] OpenClaw request failed:", error);
|
||||||
return NextResponse.json(
|
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(
|
const existingMessages = await getMessagesByChatId({ id: parsed.id }).catch(
|
||||||
() => []
|
() => []
|
||||||
);
|
);
|
||||||
@ -366,6 +419,7 @@ export async function POST(request: Request) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
await saveMessages({ messages: [userMessage, assistantMessage] });
|
await saveMessages({ messages: [userMessage, assistantMessage] });
|
||||||
|
clearClawdRequestCancellation(cancelKey);
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
conversationId,
|
conversationId,
|
||||||
|
|||||||
@ -25,7 +25,7 @@ import {
|
|||||||
type UiSurfaceSnapshot,
|
type UiSurfaceSnapshot,
|
||||||
} from "@/lib/ui-surface-protocol";
|
} from "@/lib/ui-surface-protocol";
|
||||||
import useSWR, { useSWRConfig } from "swr";
|
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 { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
Collapsible,
|
Collapsible,
|
||||||
@ -461,6 +461,16 @@ type ClawdComposerSubmissionSnapshot = {
|
|||||||
submittedTexts: string[];
|
submittedTexts: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type PendingClawdRequestRestoreSnapshot = {
|
||||||
|
chatId: string;
|
||||||
|
userMessageId: string;
|
||||||
|
textContent: string;
|
||||||
|
tableFiles: File[];
|
||||||
|
attachments: PickedObjectAttachment[];
|
||||||
|
skillTag: CanvasSkillTag | null;
|
||||||
|
shouldRestoreSkillTag: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
function isClawdSubmittedComposerText(
|
function isClawdSubmittedComposerText(
|
||||||
snapshot: ClawdComposerSubmissionSnapshot,
|
snapshot: ClawdComposerSubmissionSnapshot,
|
||||||
value: string
|
value: string
|
||||||
@ -1699,9 +1709,13 @@ function OpenClawQuickActions({
|
|||||||
function CanvasAssistantTypingPlaceholder({
|
function CanvasAssistantTypingPlaceholder({
|
||||||
hint,
|
hint,
|
||||||
label = "分析中",
|
label = "分析中",
|
||||||
|
onCancel,
|
||||||
|
cancelLabel = "停止",
|
||||||
}: {
|
}: {
|
||||||
hint?: string | null;
|
hint?: string | null;
|
||||||
label?: string;
|
label?: string;
|
||||||
|
onCancel?: () => void;
|
||||||
|
cancelLabel?: string;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="flex w-full justify-start">
|
<div className="flex w-full justify-start">
|
||||||
@ -1717,6 +1731,19 @@ function CanvasAssistantTypingPlaceholder({
|
|||||||
>
|
>
|
||||||
<Loader2 className="h-4 w-4 animate-spin" />
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
<span>{label}</span>
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -2369,6 +2396,10 @@ export function CanvasChatPanel({
|
|||||||
useRef<ClawdComposerSubmissionSnapshot | null>(null);
|
useRef<ClawdComposerSubmissionSnapshot | null>(null);
|
||||||
const recentClawdSubmissionRef =
|
const recentClawdSubmissionRef =
|
||||||
useRef<ClawdComposerSubmissionSnapshot | null>(null);
|
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(
|
const replaceChatSessionQuery = useCallback(
|
||||||
(sessionId: string) => {
|
(sessionId: string) => {
|
||||||
@ -7267,6 +7298,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(() => {
|
useEffect(() => {
|
||||||
activeMessagesRef.current = messages;
|
activeMessagesRef.current = messages;
|
||||||
}, [messages]);
|
}, [messages]);
|
||||||
@ -10914,6 +11015,19 @@ export function CanvasChatPanel({
|
|||||||
pendingClawdSubmissionRef.current = clawdSubmissionSnapshot;
|
pendingClawdSubmissionRef.current = clawdSubmissionSnapshot;
|
||||||
recentClawdSubmissionRef.current = clawdSubmissionSnapshot;
|
recentClawdSubmissionRef.current = clawdSubmissionSnapshot;
|
||||||
writeStoredClawdComposerSubmissionSnapshot(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);
|
setClawdProcessingHint(nextProcessingHint);
|
||||||
setIsClawdSubmitting(true);
|
setIsClawdSubmitting(true);
|
||||||
setMessages((previousMessages) =>
|
setMessages((previousMessages) =>
|
||||||
@ -10956,8 +11070,13 @@ export function CanvasChatPanel({
|
|||||||
const response = await fetch("/api/canvas/clawd-chat", {
|
const response = await fetch("/api/canvas/clawd-chat", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: formData,
|
body: formData,
|
||||||
|
signal: clawdAbortController.signal,
|
||||||
});
|
});
|
||||||
const responseData = await response.json().catch(() => null);
|
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) {
|
if (!response.ok) {
|
||||||
console.error("[CanvasChat] Claw API returned error:", {
|
console.error("[CanvasChat] Claw API returned error:", {
|
||||||
status: response.status,
|
status: response.status,
|
||||||
@ -11007,6 +11126,17 @@ export function CanvasChatPanel({
|
|||||||
void mutateClawdHistory();
|
void mutateClawdHistory();
|
||||||
hasHydratedHistoryRef.current = true;
|
hasHydratedHistoryRef.current = true;
|
||||||
} catch (error) {
|
} 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);
|
console.error("[CanvasChat] Claw request failed:", error);
|
||||||
pendingClawdSubmissionRef.current = null;
|
pendingClawdSubmissionRef.current = null;
|
||||||
if (recentClawdSubmissionRef.current === clawdSubmissionSnapshot) {
|
if (recentClawdSubmissionRef.current === clawdSubmissionSnapshot) {
|
||||||
@ -11048,13 +11178,25 @@ export function CanvasChatPanel({
|
|||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
pendingClawdSubmissionRef.current = null;
|
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 (recentClawdSubmissionRef.current === clawdSubmissionSnapshot) {
|
||||||
|
if (wasCanceled) {
|
||||||
|
recentClawdSubmissionRef.current = null;
|
||||||
|
writeStoredClawdComposerSubmissionSnapshot(null);
|
||||||
|
} else {
|
||||||
const refreshedSubmissionSnapshot = {
|
const refreshedSubmissionSnapshot = {
|
||||||
...clawdSubmissionSnapshot,
|
...clawdSubmissionSnapshot,
|
||||||
expiresAt: Date.now() + CLAWD_SUBMITTED_COMPOSER_RECOVERY_TTL_MS,
|
expiresAt: Date.now() + CLAWD_SUBMITTED_COMPOSER_RECOVERY_TTL_MS,
|
||||||
};
|
};
|
||||||
recentClawdSubmissionRef.current = refreshedSubmissionSnapshot;
|
recentClawdSubmissionRef.current = refreshedSubmissionSnapshot;
|
||||||
writeStoredClawdComposerSubmissionSnapshot(refreshedSubmissionSnapshot);
|
writeStoredClawdComposerSubmissionSnapshot(refreshedSubmissionSnapshot);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
setIsClawdSubmitting(false);
|
setIsClawdSubmitting(false);
|
||||||
setClawdProcessingHint(null);
|
setClawdProcessingHint(null);
|
||||||
@ -11427,6 +11569,7 @@ export function CanvasChatPanel({
|
|||||||
markSessionClawdSkillTagPromptApplied,
|
markSessionClawdSkillTagPromptApplied,
|
||||||
updateChatSessions,
|
updateChatSessions,
|
||||||
buildHistoryMessagesUrl,
|
buildHistoryMessagesUrl,
|
||||||
|
restorePendingClawdRequestToComposer,
|
||||||
workflowContext,
|
workflowContext,
|
||||||
messages,
|
messages,
|
||||||
]);
|
]);
|
||||||
@ -12391,6 +12534,7 @@ export function CanvasChatPanel({
|
|||||||
isClawdMode && activeClawdSkillTag
|
isClawdMode && activeClawdSkillTag
|
||||||
? activeClawdSkillTag.placeholder?.trim() || ""
|
? activeClawdSkillTag.placeholder?.trim() || ""
|
||||||
: "";
|
: "";
|
||||||
|
const isClawdStopAvailable = isClawdMode && isClawdSubmitting;
|
||||||
const handleClawdPresetPlaceholderDoubleClick = useCallback(
|
const handleClawdPresetPlaceholderDoubleClick = useCallback(
|
||||||
(event: ReactMouseEvent<HTMLDivElement>) => {
|
(event: ReactMouseEvent<HTMLDivElement>) => {
|
||||||
if (isSubmitting || !isClawdMode || !clawdEditablePresetPlaceholder) return;
|
if (isSubmitting || !isClawdMode || !clawdEditablePresetPlaceholder) return;
|
||||||
@ -14177,7 +14321,12 @@ export function CanvasChatPanel({
|
|||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{isClawdMode && isClawdSubmitting ? (
|
{isClawdMode && isClawdSubmitting ? (
|
||||||
<CanvasAssistantTypingPlaceholder hint={clawdProcessingHint} label="分析中" />
|
<CanvasAssistantTypingPlaceholder
|
||||||
|
cancelLabel="停止并编辑"
|
||||||
|
hint={clawdProcessingHint}
|
||||||
|
label="分析中"
|
||||||
|
onCancel={handleCancelClawdSubmission}
|
||||||
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<div ref={messagesEndRef} />
|
<div ref={messagesEndRef} />
|
||||||
@ -16085,7 +16234,9 @@ export function CanvasChatPanel({
|
|||||||
isWorkspacePresentation
|
isWorkspacePresentation
|
||||||
? "h-10 rounded-full px-4 transition-all duration-200 flex-shrink-0"
|
? "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",
|
: "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
|
? canSend
|
||||||
? "border border-[#e7ff1a]/20 bg-[#e7ff1a] text-zinc-950 shadow-sm hover:bg-[#dff40f]"
|
? "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]"
|
: "border border-white/10 bg-[#242529] text-zinc-500 hover:bg-[#2c2d33]"
|
||||||
@ -16097,15 +16248,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-[#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]"
|
: "bg-[#f1f1f2] text-[#b6bac2] hover:bg-[#ececee] dark:bg-[#242529] dark:text-[#777d88] dark:hover:bg-[#2c2d33]"
|
||||||
)}
|
)}
|
||||||
onClick={() => void handleSend()}
|
onClick={() => {
|
||||||
disabled={isSubmitting || !canSend}
|
if (isClawdStopAvailable) {
|
||||||
|
handleCancelClawdSubmission();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
void handleSend();
|
||||||
|
}}
|
||||||
|
disabled={isClawdStopAvailable ? false : isSubmitting || !canSend}
|
||||||
title={
|
title={
|
||||||
isWorkspacePresentation || isDetailGenerationMode
|
isClawdStopAvailable
|
||||||
|
? "停止处理并重新编辑"
|
||||||
|
: isWorkspacePresentation || isDetailGenerationMode
|
||||||
? getToolbarItem("chatStartGeneration").label
|
? getToolbarItem("chatStartGeneration").label
|
||||||
: getToolbarItem("chatSend").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" />
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
{isWorkspacePresentation ? <span className="ml-2">生成中</span> : null}
|
{isWorkspacePresentation ? <span className="ml-2">生成中</span> : null}
|
||||||
|
|||||||
76
lib/openclaw-chat/cancel-registry.ts
Normal file
76
lib/openclaw-chat/cancel-registry.ts
Normal 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);
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user