diff --git a/components/canvas/CanvasChatPanel.tsx b/components/canvas/CanvasChatPanel.tsx index 62825f464..fc893dce9 100644 --- a/components/canvas/CanvasChatPanel.tsx +++ b/components/canvas/CanvasChatPanel.tsx @@ -269,6 +269,7 @@ import { extractCanvasModelName, extractImageUrls, extractVideoUrls, + filterStandaloneAssistantImageUrls, formatMessageDateLabel, getAssistantImageNodeIdentityKey, getCanvasAssistantMessagesAfterTargetUser, @@ -285,6 +286,13 @@ import { stripCanvasModelRoutingLines, stripMediaMarkup, } from "@/components/canvas/chat-panel/canvas-chat-media"; +import { + clearClawdRequestState, + startClawdRequestState, + type ClawdRequestState, + type ClawdRequestStateByChatId, + updateClawdRequestState, +} from "@/components/canvas/chat-panel/clawd-request-state"; import { buildOpenClawVideoDisplayText, buildOpenClawVideoReferenceText, @@ -2623,11 +2631,9 @@ export function CanvasChatPanel({ const [isDetailGenerationMode, setIsDetailGenerationMode] = useState(false); const [detailGenerationMode, setDetailGenerationMode] = useState("classic"); const [isDetailGenerating, setIsDetailGenerating] = useState(false); - const [isClawdSubmitting, setIsClawdSubmitting] = useState(false); - const [clawdProcessingHint, setClawdProcessingHint] = useState(null); - const [clawdProgressEvents, setClawdProgressEvents] = useState< - ClawdProgressEvent[] - >([]); + const [clawdRequestUiByChatId, setClawdRequestUiByChatId] = useState< + ClawdRequestStateByChatId + >({}); const [detailSections, setDetailSections] = useState>({ required: true, strategy: false, @@ -2687,6 +2693,10 @@ export function CanvasChatPanel({ const [activeChatId, setActiveChatId] = useState( () => requestedChatSessionId || chatId ); + const activeClawdRequestUi = clawdRequestUiByChatId[activeChatId] || null; + const isClawdSubmitting = Boolean(activeClawdRequestUi); + const clawdProcessingHint = activeClawdRequestUi?.hint || null; + const clawdProgressEvents = activeClawdRequestUi?.progressEvents || []; const activeChatIdRef = useRef(requestedChatSessionId || chatId); const activeMessagesRef = useRef([]); const inFlightChatIdRef = useRef(null); @@ -2748,9 +2758,12 @@ export function CanvasChatPanel({ useRef(null); const recentClawdSubmissionRef = useRef(null); - const clawdAbortControllerRef = useRef(null); - const pendingClawdRequestRestoreRef = - useRef(null); + const clawdAbortControllersRef = useRef< + Map + >(new Map()); + const pendingClawdRequestRestoresRef = useRef< + Map + >(new Map()); const canceledClawdMessageIdsRef = useRef>(new Set()); const replaceChatSessionQuery = useCallback( @@ -2914,6 +2927,49 @@ export function CanvasChatPanel({ [getSendGuard] ); + const startClawdRequestUi = useCallback( + (chatSessionId: string, userMessageId: string, hint: string | null) => { + setClawdRequestUiByChatId((previousState) => + startClawdRequestState( + previousState, + chatSessionId, + userMessageId, + hint + ) + ); + }, + [] + ); + + const updateClawdRequestUi = useCallback( + ( + chatSessionId: string, + userMessageId: string, + updater: ( + currentState: ClawdRequestState + ) => ClawdRequestState + ) => { + setClawdRequestUiByChatId((previousState) => + updateClawdRequestState( + previousState, + chatSessionId, + userMessageId, + updater + ) + ); + }, + [] + ); + + const clearClawdRequestUi = useCallback( + (chatSessionId: string, userMessageId: string) => { + setClawdRequestUiByChatId((previousState) => + clearClawdRequestState(previousState, chatSessionId, userMessageId) + ); + }, + [] + ); + const setStreamImageGenerationStateForChat = useCallback( (chatSessionId: string, next: ImageGenerationStatusStreamPayload | null) => { setStreamImageGenerationStateByChatId((prev) => ({ @@ -3026,17 +3082,15 @@ export function CanvasChatPanel({ }; forceHistoryHydrationRef.current = null; - releaseSendGuard(activeChatId); updateChatSessions((prevSessions) => [newSession, ...prevSessions]); + activeChatIdRef.current = newChatSessionId; setActiveChatId(newChatSessionId); replaceChatSessionQuery(newChatSessionId); toast.success(options?.toastMessage || "已新建聊天窗口"); return newChatSessionId; }, [ - activeChatId, chatSessions, - releaseSendGuard, replaceChatSessionQuery, updateChatSessions, ] @@ -3270,18 +3324,9 @@ export function CanvasChatPanel({ const isClawdSubmissionForCurrentComposer = useCallback( (submissionSnapshot: ClawdComposerSubmissionSnapshot) => { - if (submissionSnapshot.chatId === activeChatIdRef.current) return true; - - if (submissionSnapshot.canvasId && submissionSnapshot.canvasId !== canvasId) { - return false; - } - if (submissionSnapshot.chatScope && submissionSnapshot.chatScope !== chatScope) { - return false; - } - - return Boolean(submissionSnapshot.canvasId || submissionSnapshot.chatScope); + return submissionSnapshot.chatId === activeChatIdRef.current; }, - [canvasId, chatScope] + [] ); const clearRestoredClawdComposerText = useCallback(() => { @@ -7914,14 +7959,18 @@ export function CanvasChatPanel({ ); const handleCancelClawdSubmission = useCallback(() => { - const snapshot = pendingClawdRequestRestoreRef.current; + const snapshot = pendingClawdRequestRestoresRef.current.get(activeChatId); if (!snapshot) return; canceledClawdMessageIdsRef.current.add(snapshot.userMessageId); - pendingClawdRequestRestoreRef.current = null; - pendingClawdSubmissionRef.current = null; - recentClawdSubmissionRef.current = null; - writeStoredClawdComposerSubmissionSnapshot(null); + pendingClawdRequestRestoresRef.current.delete(activeChatId); + if (pendingClawdSubmissionRef.current?.chatId === activeChatId) { + pendingClawdSubmissionRef.current = null; + } + if (recentClawdSubmissionRef.current?.chatId === activeChatId) { + recentClawdSubmissionRef.current = null; + writeStoredClawdComposerSubmissionSnapshot(null); + } void fetch("/api/canvas/clawd-chat/cancel", { method: "POST", @@ -7934,25 +7983,31 @@ export function CanvasChatPanel({ console.warn("[CanvasChat] Claw cancel request failed:", error); }); - clawdAbortControllerRef.current?.abort(); - clawdAbortControllerRef.current = null; - setIsClawdSubmitting(false); - setClawdProcessingHint(null); - setClawdProgressEvents([]); + const activeController = clawdAbortControllersRef.current.get(activeChatId); + if (activeController?.userMessageId === snapshot.userMessageId) { + activeController.controller.abort(); + clawdAbortControllersRef.current.delete(activeChatId); + } + clearClawdRequestUi(activeChatId, snapshot.userMessageId); releaseSendGuard(snapshot.chatId); restorePendingClawdRequestToComposer(snapshot); toast.message("已停止处理,可以重新编辑后再发送"); - }, [releaseSendGuard, restorePendingClawdRequestToComposer]); + }, [ + activeChatId, + clearClawdRequestUi, + releaseSendGuard, + restorePendingClawdRequestToComposer, + ]); useEffect(() => { activeMessagesRef.current = messages; }, [messages]); useEffect(() => { - if (status === "ready") { + if (status === "ready" && !isClawdSubmitting) { releaseSendGuard(activeChatId); } - }, [activeChatId, releaseSendGuard, status]); + }, [activeChatId, isClawdSubmitting, releaseSendGuard, status]); useEffect(() => { const launchPayload = pendingLaunchRef.current; @@ -11419,7 +11474,12 @@ export function CanvasChatPanel({ // 处理发送(图片已在上传时添加到画布,这里只发送消息) const handleSend = useCallback(async (overrideInput?: string) => { const currentSendGuard = getSendGuard(activeChatId); - if (currentSendGuard.inFlight || status === "streaming" || status === "submitted") { + if ( + currentSendGuard.inFlight || + isClawdSubmitting || + status === "streaming" || + status === "submitted" + ) { return; } const hasOverrideInput = typeof overrideInput === "string"; @@ -11441,6 +11501,8 @@ export function CanvasChatPanel({ } if (isClawdMode) { + const requestChatId = activeChatId; + const requestRawChatId = rawChatId; const isProgrammaticClawdQuickSend = hasOverrideInput; const textContent = normalizeClawdComposerText(composerInput); const tableFilesSnapshot = isProgrammaticClawdQuickSend ? [] : [...clawdTableFiles]; @@ -11549,7 +11611,7 @@ export function CanvasChatPanel({ .filter(Boolean) .join("\n\n"); const sendSignature = JSON.stringify({ - chatId: activeChatId, + chatId: requestChatId, mode: "clawd", text: requestMessage, skillTagId: clawdSkillTagForRequest?.id || null, @@ -11567,7 +11629,7 @@ export function CanvasChatPanel({ .sort(), }); - if (shouldBlockDuplicateSend(activeChatId, sendSignature)) { + if (shouldBlockDuplicateSend(requestChatId, sendSignature)) { toast.message("检测到重复发送,已自动忽略"); return; } @@ -11613,7 +11675,7 @@ export function CanvasChatPanel({ updateChatSessions((prevSessions) => prevSessions.map((session) => - session.id === activeChatId + session.id === requestChatId ? { ...session, mode: "clawd", @@ -11622,14 +11684,14 @@ export function CanvasChatPanel({ : session ) ); - setSendGuard(activeChatId, { + setSendGuard(requestChatId, { inFlight: true, signature: sendSignature, timestamp: Date.now(), }); const clawdSubmissionStartedAt = Date.now(); const clawdSubmissionSnapshot: ClawdComposerSubmissionSnapshot = { - chatId: activeChatId, + chatId: requestChatId, canvasId, chatScope, expiresAt: @@ -11650,7 +11712,7 @@ export function CanvasChatPanel({ writeStoredClawdComposerSubmissionSnapshot(clawdSubmissionSnapshot); const clawdAbortController = new AbortController(); const clawdRequestRestoreSnapshot: PendingClawdRequestRestoreSnapshot = { - chatId: activeChatId, + chatId: requestChatId, userMessageId, textContent, tableFiles: tableFilesSnapshot, @@ -11659,11 +11721,15 @@ export function CanvasChatPanel({ shouldRestoreSkillTag: hasPendingClawdSkillTagPrompt, }; canceledClawdMessageIdsRef.current.delete(userMessageId); - clawdAbortControllerRef.current = clawdAbortController; - pendingClawdRequestRestoreRef.current = clawdRequestRestoreSnapshot; - setClawdProcessingHint(nextProcessingHint); - setClawdProgressEvents([]); - setIsClawdSubmitting(true); + clawdAbortControllersRef.current.set(requestChatId, { + userMessageId, + controller: clawdAbortController, + }); + pendingClawdRequestRestoresRef.current.set( + requestChatId, + clawdRequestRestoreSnapshot + ); + startClawdRequestUi(requestChatId, userMessageId, nextProcessingHint); setMessages((previousMessages) => sortCanvasMessagesByCreatedAt([ ...previousMessages.filter((message) => message.id !== userMessageId), @@ -11683,7 +11749,7 @@ export function CanvasChatPanel({ let clawdRequestAccepted = false; try { const formData = new FormData(); - formData.append("id", rawChatId); + formData.append("id", requestRawChatId); formData.append("userMessageId", userMessageId); formData.append("message", requestMessage); formData.append("displayMessage", optimisticMessageText); @@ -11691,7 +11757,7 @@ export function CanvasChatPanel({ "metadata", JSON.stringify({ composerMode: "clawd", - activeChatId, + activeChatId: requestChatId, imageAttachmentCount: imageAttachmentsSnapshot.length || undefined, videoAttachmentCount: videoAttachmentsSnapshot.length || undefined, skillTagId: clawdSkillTagForRequest?.id || undefined, @@ -11719,21 +11785,26 @@ export function CanvasChatPanel({ const display = String( progress.display || progress.message || "" ).trim(); - if (display) { - setClawdProcessingHint(display); - } const normalizedProgress = normalizeClawdProgressEvent(progress); - if (normalizedProgress) { - setClawdProgressEvents((previousEvents) => { - if ( - previousEvents.some( - (item) => item.key === normalizedProgress.key - ) - ) { - return previousEvents; + if (display || normalizedProgress) { + updateClawdRequestUi( + requestChatId, + userMessageId, + (currentState) => { + const nextProgressEvents = normalizedProgress + ? currentState.progressEvents.some( + (item) => item.key === normalizedProgress.key + ) + ? currentState.progressEvents + : [...currentState.progressEvents, normalizedProgress].slice(-2000) + : currentState.progressEvents; + return { + ...currentState, + hint: display || currentState.hint, + progressEvents: nextProgressEvents, + }; } - return [...previousEvents, normalizedProgress].slice(-2000); - }); + ); } }); if (response.status === 499 || responseData?.canceled === true) { @@ -11750,7 +11821,7 @@ export function CanvasChatPanel({ if (clawdSkillTagPrompt && clawdSkillTagForRequest) { markSessionClawdSkillTagPromptApplied( - activeChatId, + requestChatId, clawdSkillTagForRequest.id ); } @@ -11758,7 +11829,10 @@ export function CanvasChatPanel({ const returnedMessages = Array.isArray(responseData?.messages) ? (responseData.messages as ChatMessage[]) : []; - if (returnedMessages.length > 0) { + if ( + returnedMessages.length > 0 && + activeChatIdRef.current === requestChatId + ) { const returnedIds = new Set(returnedMessages.map((message) => message.id)); const returnedAssistantIds = returnedMessages .filter((message) => message.role === "assistant") @@ -11783,8 +11857,10 @@ export function CanvasChatPanel({ ]) ); } - if (rawChatId) { - void mutate(buildHistoryMessagesUrl(rawChatId, { cacheBust: true })); + if (requestRawChatId) { + void mutate( + buildHistoryMessagesUrl(requestRawChatId, { cacheBust: true }) + ); } void mutateClawdHistory(); hasHydratedHistoryRef.current = true; @@ -11793,20 +11869,32 @@ export function CanvasChatPanel({ canceledClawdMessageIdsRef.current.has(userMessageId) || (error instanceof Error && error.name === "AbortError"); if (wasCanceled) { - if (pendingClawdRequestRestoreRef.current === clawdRequestRestoreSnapshot) { - pendingClawdRequestRestoreRef.current = null; + const shouldRestoreRequest = + pendingClawdRequestRestoresRef.current.get(requestChatId) === + clawdRequestRestoreSnapshot; + if (shouldRestoreRequest) { + pendingClawdRequestRestoresRef.current.delete(requestChatId); + } + if ( + shouldRestoreRequest && + activeChatIdRef.current === requestChatId + ) { restorePendingClawdRequestToComposer(clawdRequestRestoreSnapshot); toast.message("已停止处理,可以重新编辑后再发送"); } return; } console.error("[CanvasChat] Claw request failed:", error); - pendingClawdSubmissionRef.current = null; + if (pendingClawdSubmissionRef.current === clawdSubmissionSnapshot) { + pendingClawdSubmissionRef.current = null; + } if (clawdRequestAccepted) { toast.error("Claw 连接中断,已保留已发送消息;任务可能仍在处理中,请稍后刷新历史查看"); void mutateClawdHistory(); - if (rawChatId) { - void mutate(buildHistoryMessagesUrl(rawChatId, { cacheBust: true })); + if (requestRawChatId) { + void mutate( + buildHistoryMessagesUrl(requestRawChatId, { cacheBust: true }) + ); } return; } @@ -11814,11 +11902,16 @@ export function CanvasChatPanel({ recentClawdSubmissionRef.current = null; writeStoredClawdComposerSubmissionSnapshot(null); } - setMessages((previousMessages) => - previousMessages.filter((message) => message.id !== userMessageId) - ); + if (activeChatIdRef.current === requestChatId) { + setMessages((previousMessages) => + previousMessages.filter((message) => message.id !== userMessageId) + ); + } toast.error(CLAW_UPGRADE_RETRY_MESSAGE); - if (!isProgrammaticClawdQuickSend) { + if ( + !isProgrammaticClawdQuickSend && + activeChatIdRef.current === requestChatId + ) { setClawdTableFiles(tableFilesSnapshot); setAttachments(attachmentsSnapshot); if (clawdSkillTagForRequest && hasPendingClawdSkillTagPrompt) { @@ -11849,12 +11942,22 @@ export function CanvasChatPanel({ } } } finally { - pendingClawdSubmissionRef.current = null; - if (pendingClawdRequestRestoreRef.current === clawdRequestRestoreSnapshot) { - pendingClawdRequestRestoreRef.current = null; + if (pendingClawdSubmissionRef.current === clawdSubmissionSnapshot) { + pendingClawdSubmissionRef.current = null; } - if (clawdAbortControllerRef.current === clawdAbortController) { - clawdAbortControllerRef.current = null; + if ( + pendingClawdRequestRestoresRef.current.get(requestChatId) === + clawdRequestRestoreSnapshot + ) { + pendingClawdRequestRestoresRef.current.delete(requestChatId); + } + const registeredController = + clawdAbortControllersRef.current.get(requestChatId); + if ( + registeredController?.userMessageId === userMessageId && + registeredController.controller === clawdAbortController + ) { + clawdAbortControllersRef.current.delete(requestChatId); } const wasCanceled = canceledClawdMessageIdsRef.current.delete(userMessageId); if (recentClawdSubmissionRef.current === clawdSubmissionSnapshot) { @@ -11871,10 +11974,8 @@ export function CanvasChatPanel({ scheduleClearRestoredClawdComposerText(); } } - setIsClawdSubmitting(false); - setClawdProcessingHint(null); - setClawdProgressEvents([]); - releaseSendGuard(activeChatId); + clearClawdRequestUi(requestChatId, userMessageId); + releaseSendGuard(requestChatId); } return; } @@ -12269,6 +12370,7 @@ export function CanvasChatPanel({ selectedImageNodes, isDetailGenerationMode, isClawdMode, + isClawdSubmitting, canUseDetailGeneration, handleDetailGeneration, ensureImagePointsBeforeSend, @@ -12291,6 +12393,9 @@ export function CanvasChatPanel({ updateChatSessions, buildHistoryMessagesUrl, restorePendingClawdRequestToComposer, + startClawdRequestUi, + updateClawdRequestUi, + clearClawdRequestUi, workflowContext, messages, ]); @@ -14033,12 +14138,12 @@ export function CanvasChatPanel({ return; } forceHistoryHydrationRef.current = toRawCanvasChatId(sessionId); - releaseSendGuard(activeChatId); + activeChatIdRef.current = sessionId; setActiveChatId(sessionId); replaceChatSessionQuery(sessionId); setIsSessionMenuOpen(false); toast.success("已切换聊天窗口"); - }, [activeChatId, releaseSendGuard, replaceChatSessionQuery]); + }, [activeChatId, replaceChatSessionQuery]); const switchToClawdHistorySession = useCallback( (historyItem: ClawdHistoryItem) => { @@ -14082,7 +14187,7 @@ export function CanvasChatPanel({ }); forceHistoryHydrationRef.current = toRawCanvasChatId(sessionId); - releaseSendGuard(activeChatId); + activeChatIdRef.current = sessionId; setActiveChatId(sessionId); setComposerModeOverride("clawd"); setIsDetailGenerationMode(false); @@ -14090,7 +14195,7 @@ export function CanvasChatPanel({ setIsClawdHistoryMenuOpen(false); toast.success("已打开 Claw 历史对话"); }, - [activeChatId, releaseSendGuard, replaceChatSessionQuery, updateChatSessions] + [replaceChatSessionQuery, updateChatSessions] ); const formatClawdHistoryTime = useCallback((value: string | Date) => { @@ -15026,9 +15131,15 @@ export function CanvasChatPanel({ const shouldDeferAssistantMedia = (isClawAssistantMessage || shouldTypeAgentAssistantMessage) && (shouldAnimateAnalysis || shouldAnimatePlainText); + const standaloneImageUrls = isClawAssistantMessage + ? filterStandaloneAssistantImageUrls( + rawText, + imageUrls + ) + : imageUrls; const visibleImageUrls = shouldDeferAssistantMedia ? [] - : imageUrls; + : standaloneImageUrls; const visibleVideoUrls = shouldDeferAssistantMedia ? [] : videoUrls; diff --git a/components/canvas/chat-panel/canvas-chat-media.ts b/components/canvas/chat-panel/canvas-chat-media.ts index 88d2e2269..6d0ba2b0c 100644 --- a/components/canvas/chat-panel/canvas-chat-media.ts +++ b/components/canvas/chat-panel/canvas-chat-media.ts @@ -250,6 +250,83 @@ export function extractAssistantImageUrls( }); } +function isMarkdownTableRow(line: string): boolean { + const trimmed = String(line || "").trim(); + if (!trimmed || !trimmed.includes("|")) return false; + + const cells = trimmed + .replace(/^\|/, "") + .replace(/\|$/, "") + .split("|"); + return cells.length >= 2; +} + +function isMarkdownTableSeparator(line: string): boolean { + if (!isMarkdownTableRow(line)) return false; + + const cells = String(line || "") + .trim() + .replace(/^\|/, "") + .replace(/\|$/, "") + .split("|") + .map((cell) => cell.trim().replace(/\s+/g, "")); + return cells.length >= 2 && cells.every((cell) => /^:?-{3,}:?$/.test(cell)); +} + +export function getMarkdownTableImageUrlKeys(text: string): Set { + const urlKeys = new Set(); + const lines = String(text || "").replace(/\r\n/g, "\n").split("\n"); + let inCodeFence = false; + + const addUrl = (url: string) => { + const key = normalizeImageIdentityUrl(url); + if (key) urlKeys.add(key); + }; + + for (let index = 0; index < lines.length - 1; index += 1) { + const line = lines[index] || ""; + if (/^\s*```/.test(line)) { + inCodeFence = !inCodeFence; + continue; + } + if ( + inCodeFence || + !isMarkdownTableRow(line) || + !isMarkdownTableSeparator(lines[index + 1] || "") + ) { + continue; + } + + let endIndex = index + 2; + while ( + endIndex < lines.length && + isMarkdownTableRow(lines[endIndex] || "") + ) { + endIndex += 1; + } + + const tableText = lines.slice(index, endIndex).join("\n"); + extractAssistantImageUrls(tableText).forEach(addUrl); + extractPlainImageUrls(tableText).forEach(addUrl); + index = endIndex - 1; + } + + return urlKeys; +} + +export function filterStandaloneAssistantImageUrls( + text: string, + imageUrls: string[] +): string[] { + const tableImageUrlKeys = getMarkdownTableImageUrlKeys(text); + if (tableImageUrlKeys.size === 0) return imageUrls; + + return imageUrls.filter((url) => { + const key = normalizeImageIdentityUrl(url); + return !key || !tableImageUrlKeys.has(key); + }); +} + export function extractVideoUrls(text: string): string[] { if (!text) return []; const urls = new Set(); diff --git a/components/canvas/chat-panel/clawd-request-state.ts b/components/canvas/chat-panel/clawd-request-state.ts new file mode 100644 index 000000000..80a74b9fc --- /dev/null +++ b/components/canvas/chat-panel/clawd-request-state.ts @@ -0,0 +1,60 @@ +export type ClawdRequestState = { + userMessageId: string; + hint: string | null; + progressEvents: TProgress[]; +}; + +export type ClawdRequestStateByChatId = Record< + string, + ClawdRequestState +>; + +export function startClawdRequestState( + previousState: ClawdRequestStateByChatId, + chatSessionId: string, + userMessageId: string, + hint: string | null +): ClawdRequestStateByChatId { + return { + ...previousState, + [chatSessionId]: { + userMessageId, + hint, + progressEvents: [], + }, + }; +} + +export function updateClawdRequestState( + previousState: ClawdRequestStateByChatId, + chatSessionId: string, + userMessageId: string, + updater: ( + currentState: ClawdRequestState + ) => ClawdRequestState +): ClawdRequestStateByChatId { + const currentState = previousState[chatSessionId]; + if (!currentState || currentState.userMessageId !== userMessageId) { + return previousState; + } + + return { + ...previousState, + [chatSessionId]: updater(currentState), + }; +} + +export function clearClawdRequestState( + previousState: ClawdRequestStateByChatId, + chatSessionId: string, + userMessageId: string +): ClawdRequestStateByChatId { + const currentState = previousState[chatSessionId]; + if (!currentState || currentState.userMessageId !== userMessageId) { + return previousState; + } + + const nextState = { ...previousState }; + delete nextState[chatSessionId]; + return nextState; +} diff --git a/tests/routes/canvas-chat-helpers.test.ts b/tests/routes/canvas-chat-helpers.test.ts index c4b94d227..e25dcdff5 100644 --- a/tests/routes/canvas-chat-helpers.test.ts +++ b/tests/routes/canvas-chat-helpers.test.ts @@ -3,6 +3,8 @@ import { collectMessageImageUrls, extractAssistantImageUrls, extractVideoUrls, + filterStandaloneAssistantImageUrls, + getMarkdownTableImageUrlKeys, normalizeMediaUrl, sortCanvasMessagesByCreatedAt, stripMediaMarkup, @@ -5175,6 +5177,29 @@ test.describe("canvas chat pure helpers", () => { expect(urls).toEqual(["https://cdn.example.com/result.png?token=secret"]); }); + test("does not append Claw images that already render inside markdown tables", () => { + const markdown = [ + "| 商品 | 主图 |", + "| --- | --- |", + "| A | ![主图](https://cdn.example.com/table-image.png?token=secret) |", + "| B | [查看](https://cdn.example.com/table-link.webp?token=secret) |", + "", + "补充图片:", + "![补充](https://cdn.example.com/standalone-image.jpg?token=secret)", + ].join("\n"); + const imageUrls = extractAssistantImageUrls(markdown); + + expect(getMarkdownTableImageUrlKeys(markdown)).toEqual( + new Set([ + "https://cdn.example.com/table-image.png", + "https://cdn.example.com/table-link.webp", + ]) + ); + expect(filterStandaloneAssistantImageUrls(markdown, imageUrls)).toEqual([ + "https://cdn.example.com/standalone-image.jpg?token=secret", + ]); + }); + test("extracts and strips video markup without removing surrounding text", () => { const text = [ "生成完成", diff --git a/tests/routes/openclaw-request-state.test.ts b/tests/routes/openclaw-request-state.test.ts new file mode 100644 index 000000000..917d36967 --- /dev/null +++ b/tests/routes/openclaw-request-state.test.ts @@ -0,0 +1,57 @@ +import { expect, test } from "@playwright/test"; +import { + clearClawdRequestState, + startClawdRequestState, + updateClawdRequestState, +} from "@/components/canvas/chat-panel/clawd-request-state"; + +test.describe("OpenClaw request state isolation", () => { + test("keeps progress and completion scoped to the originating chat", () => { + let state = startClawdRequestState({}, "chat-a", "message-a", "处理中 A"); + state = startClawdRequestState(state, "chat-b", "message-b", "处理中 B"); + state = updateClawdRequestState( + state, + "chat-a", + "message-a", + (currentState) => ({ + ...currentState, + progressEvents: ["A 第一步"], + }) + ); + + expect(state["chat-a"]?.progressEvents).toEqual(["A 第一步"]); + expect(state["chat-b"]?.progressEvents).toEqual([]); + + state = clearClawdRequestState(state, "chat-a", "message-a"); + + expect(state["chat-a"]).toBeUndefined(); + expect(state["chat-b"]?.hint).toBe("处理中 B"); + }); + + test("ignores stale callbacks from an older request in the same chat", () => { + let state = startClawdRequestState({}, "chat-a", "old-message", null); + state = startClawdRequestState(state, "chat-a", "new-message", "新任务"); + + const afterStaleUpdate = updateClawdRequestState( + state, + "chat-a", + "old-message", + (currentState) => ({ + ...currentState, + progressEvents: ["旧任务进度"], + }) + ); + const afterStaleClear = clearClawdRequestState( + afterStaleUpdate, + "chat-a", + "old-message" + ); + + expect(afterStaleClear).toBe(state); + expect(afterStaleClear["chat-a"]).toEqual({ + userMessageId: "new-message", + hint: "新任务", + progressEvents: [], + }); + }); +});