fix(catalog): isolate claw requests and dedupe table images
This commit is contained in:
parent
c5c49b52a9
commit
5e74c44430
@ -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;
|
||||
|
||||
@ -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>();
|
||||
|
||||
60
components/canvas/chat-panel/clawd-request-state.ts
Normal file
60
components/canvas/chat-panel/clawd-request-state.ts
Normal 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;
|
||||
}
|
||||
@ -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 |  |",
|
||||
"| B | [查看](https://cdn.example.com/table-link.webp?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 = [
|
||||
"生成完成",
|
||||
|
||||
57
tests/routes/openclaw-request-state.test.ts
Normal file
57
tests/routes/openclaw-request-state.test.ts
Normal 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: [],
|
||||
});
|
||||
});
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user