fix(catalog): isolate claw requests and dedupe table images

This commit is contained in:
134 2026-08-03 11:10:00 +08:00
parent c5c49b52a9
commit 5e74c44430
5 changed files with 419 additions and 89 deletions

View File

@ -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<DetailGenerationMode>("classic");
const [isDetailGenerating, setIsDetailGenerating] = useState(false);
const [isClawdSubmitting, setIsClawdSubmitting] = useState(false);
const [clawdProcessingHint, setClawdProcessingHint] = useState<string | null>(null);
const [clawdProgressEvents, setClawdProgressEvents] = useState<
ClawdProgressEvent[]
>([]);
const [clawdRequestUiByChatId, setClawdRequestUiByChatId] = useState<
ClawdRequestStateByChatId<ClawdProgressEvent>
>({});
const [detailSections, setDetailSections] = useState<Record<DetailSectionKey, boolean>>({
required: true,
strategy: false,
@ -2687,6 +2693,10 @@ export function CanvasChatPanel({
const [activeChatId, setActiveChatId] = useState<string>(
() => requestedChatSessionId || chatId
);
const activeClawdRequestUi = clawdRequestUiByChatId[activeChatId] || null;
const isClawdSubmitting = Boolean(activeClawdRequestUi);
const clawdProcessingHint = activeClawdRequestUi?.hint || null;
const clawdProgressEvents = activeClawdRequestUi?.progressEvents || [];
const activeChatIdRef = useRef<string>(requestedChatSessionId || chatId);
const activeMessagesRef = useRef<ChatMessage[]>([]);
const inFlightChatIdRef = useRef<string | null>(null);
@ -2748,9 +2758,12 @@ 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 clawdAbortControllersRef = useRef<
Map<string, { userMessageId: string; controller: AbortController }>
>(new Map());
const pendingClawdRequestRestoresRef = useRef<
Map<string, PendingClawdRequestRestoreSnapshot>
>(new Map());
const canceledClawdMessageIdsRef = useRef<Set<string>>(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<ClawdProgressEvent>
) => ClawdRequestState<ClawdProgressEvent>
) => {
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;

View File

@ -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<string> {
const urlKeys = new Set<string>();
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<string>();

View File

@ -0,0 +1,60 @@
export type ClawdRequestState<TProgress> = {
userMessageId: string;
hint: string | null;
progressEvents: TProgress[];
};
export type ClawdRequestStateByChatId<TProgress> = Record<
string,
ClawdRequestState<TProgress>
>;
export function startClawdRequestState<TProgress>(
previousState: ClawdRequestStateByChatId<TProgress>,
chatSessionId: string,
userMessageId: string,
hint: string | null
): ClawdRequestStateByChatId<TProgress> {
return {
...previousState,
[chatSessionId]: {
userMessageId,
hint,
progressEvents: [],
},
};
}
export function updateClawdRequestState<TProgress>(
previousState: ClawdRequestStateByChatId<TProgress>,
chatSessionId: string,
userMessageId: string,
updater: (
currentState: ClawdRequestState<TProgress>
) => ClawdRequestState<TProgress>
): ClawdRequestStateByChatId<TProgress> {
const currentState = previousState[chatSessionId];
if (!currentState || currentState.userMessageId !== userMessageId) {
return previousState;
}
return {
...previousState,
[chatSessionId]: updater(currentState),
};
}
export function clearClawdRequestState<TProgress>(
previousState: ClawdRequestStateByChatId<TProgress>,
chatSessionId: string,
userMessageId: string
): ClawdRequestStateByChatId<TProgress> {
const currentState = previousState[chatSessionId];
if (!currentState || currentState.userMessageId !== userMessageId) {
return previousState;
}
const nextState = { ...previousState };
delete nextState[chatSessionId];
return nextState;
}

View File

@ -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 = [
"生成完成",

View File

@ -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: [],
});
});
});