fix(claw): prevent new chat history rebound

This commit is contained in:
134 2026-09-11 11:19:04 +08:00
parent a7d994d93b
commit dcff190c62
3 changed files with 188 additions and 24 deletions

View File

@ -275,8 +275,11 @@ import {
MAX_CHAT_AUTO_CONTEXT_LENGTH,
MAX_CHAT_HIDDEN_PROMPT_LENGTH,
MAX_CHAT_TEXT_PART_LENGTH,
normalizeCanvasChatSessionId,
normalizeChatAttachmentName,
normalizeChatMediaType,
replaceCanvasChatSessionHistory,
resolveCanvasChatSessionNavigationTarget,
type CanvasChatSessionRecord,
type ClawdChatHistoryRecord,
type PickedObjectAttachment,
@ -3055,15 +3058,24 @@ type CanvasChatSession = Omit<CanvasChatSessionRecord, "mode"> & {
type ClawdHistoryItem = ClawdChatHistoryRecord;
const CANVAS_CHAT_QUERY_PARAM = "chat";
const CANVAS_IMAGE_NODE_MANUAL_UPLOAD_EVENT = "canvas:image-node-manual-upload";
const CANVAS_CHAT_QUERY_PARAM = "chat";
function normalizeCanvasChatSessionId(value?: string | null) {
const raw = String(value || "").trim();
if (!raw) return null;
type CanvasHistoryMessagesResponse = {
chatId: string;
messages: ChatMessage[];
};
const rawUuid = raw.startsWith("canvas-") ? raw.slice("canvas-".length) : raw;
return isValidChatUuid(rawUuid) ? `canvas-${rawUuid}` : null;
async function fetchCanvasHistoryMessages(
url: string
): Promise<CanvasHistoryMessagesResponse> {
const payload = (await fetcher(url)) as { messages?: ChatMessage[] };
const requestUrl = new URL(url, "http://canvas.local");
return {
chatId: String(requestUrl.searchParams.get("chatId") || ""),
messages: Array.isArray(payload?.messages) ? payload.messages : [],
};
}
type CopiedUserMessagePayload = {
@ -3365,6 +3377,8 @@ export function CanvasChatPanel({
const pendingLaunchRef = useRef<CanvasLaunchPayload | null>(null);
const [launchAutoSendTick, setLaunchAutoSendTick] = useState(0);
const hasInitializedChatStateRef = useRef(false);
const initializedChatSessionStorageKeyRef = useRef<string | null>(null);
const chatSessionRootIdRef = useRef(chatId);
const [loadedChatSessionStorageKey, setLoadedChatSessionStorageKey] = useState<string | null>(
null
);
@ -3392,12 +3406,12 @@ export function CanvasChatPanel({
const replaceChatSessionQuery = useCallback(
(sessionId: string) => {
const normalized = normalizeCanvasChatSessionId(sessionId);
if (!normalized || typeof window === "undefined") return;
const url = new URL(window.location.href);
url.searchParams.set(CANVAS_CHAT_QUERY_PARAM, normalized);
window.history.replaceState(window.history.state, "", url);
if (typeof window === "undefined") return;
replaceCanvasChatSessionHistory({
currentUrl: window.location.href,
history: window.history,
sessionId,
});
},
[]
);
@ -3736,6 +3750,7 @@ export function CanvasChatPanel({
forceHistoryHydrationRef.current = null;
releaseSendGuard(activeChatId);
updateChatSessions((prevSessions) => [newSession, ...prevSessions]);
activeChatIdRef.current = newChatSessionId;
setActiveChatId(newChatSessionId);
replaceChatSessionQuery(newChatSessionId);
toast.success(options?.toastMessage || "已新建聊天窗口");
@ -4169,6 +4184,11 @@ export function CanvasChatPanel({
useEffect(() => {
const now = Date.now();
let rootChatId = chatId;
const sessionStorageKey = getSessionStorageKey(canvasId, chatScope);
if (initializedChatSessionStorageKeyRef.current === sessionStorageKey) {
return;
}
if (chatScope === "agent") {
try {
@ -4186,6 +4206,11 @@ export function CanvasChatPanel({
}
}
chatSessionRootIdRef.current = rootChatId;
const initialRequestedChatSessionId = normalizeCanvasChatSessionId(
new URL(window.location.href).searchParams.get(CANVAS_CHAT_QUERY_PARAM)
);
const fallbackSession: CanvasChatSession = {
id: rootChatId,
title: "对话 1",
@ -4194,17 +4219,17 @@ export function CanvasChatPanel({
updatedAt: now,
};
const requestedSession: CanvasChatSession | null =
requestedChatSessionId && requestedChatSessionId !== rootChatId
initialRequestedChatSessionId &&
initialRequestedChatSessionId !== rootChatId
? {
id: requestedChatSessionId,
id: initialRequestedChatSessionId,
title: "新对话",
mode: "image",
createdAt: now,
updatedAt: now,
}
: null;
const initialChatId = requestedChatSessionId || rootChatId;
const sessionStorageKey = getSessionStorageKey(canvasId, chatScope);
const initialChatId = initialRequestedChatSessionId || rootChatId;
try {
const saved = localStorage.getItem(sessionStorageKey);
@ -4248,6 +4273,7 @@ export function CanvasChatPanel({
const sorted = [...nextSessions].sort((a, b) => b.updatedAt - a.updatedAt);
setChatSessions(sorted);
activeChatIdRef.current = initialChatId;
setActiveChatId(initialChatId);
localStorage.setItem(sessionStorageKey, JSON.stringify(sorted));
setLoadedChatSessionStorageKey(sessionStorageKey);
@ -4256,10 +4282,52 @@ export function CanvasChatPanel({
setChatSessions(
requestedSession ? [requestedSession, fallbackSession] : [fallbackSession]
);
activeChatIdRef.current = initialChatId;
setActiveChatId(initialChatId);
setLoadedChatSessionStorageKey(sessionStorageKey);
} finally {
initializedChatSessionStorageKeyRef.current = sessionStorageKey;
}
}, [canvasId, chatId, chatScope, requestedChatSessionId]);
}, [canvasId, chatId, chatScope]);
useEffect(() => {
const sessionStorageKey = getSessionStorageKey(canvasId, chatScope);
if (initializedChatSessionStorageKeyRef.current !== sessionStorageKey) return;
const browserRequestedChatSessionId = normalizeCanvasChatSessionId(
new URL(window.location.href).searchParams.get(CANVAS_CHAT_QUERY_PARAM)
);
const targetChatId = resolveCanvasChatSessionNavigationTarget({
activeSessionId: activeChatIdRef.current,
browserSessionId: browserRequestedChatSessionId,
rootSessionId: chatSessionRootIdRef.current,
routerSessionId: requestedChatSessionId,
});
if (!targetChatId) return;
const now = Date.now();
updateChatSessions((previousSessions) => {
if (previousSessions.some((session) => session.id === targetChatId)) {
return previousSessions;
}
return [
{
id: targetChatId,
title: "新对话",
mode: "image",
createdAt: now,
updatedAt: now,
},
...previousSessions,
];
});
forceHistoryHydrationRef.current = requestedChatSessionId
? toRawCanvasChatId(targetChatId)
: null;
activeChatIdRef.current = targetChatId;
setActiveChatId(targetChatId);
}, [canvasId, chatScope, requestedChatSessionId, updateChatSessions]);
useEffect(() => {
if (!isSessionMenuOpen) return;
@ -4417,13 +4485,18 @@ export function CanvasChatPanel({
}, []);
// 从 API 加载历史消息
const { data: historyData, mutate: mutateHistoryData } = useSWR<{
messages: ChatMessage[];
}>(historyMessagesSWRKey, fetcher, {
revalidateOnFocus: false,
revalidateOnReconnect: false,
dedupingInterval: 10000,
});
const { data: fetchedHistoryData, mutate: mutateHistoryData } =
useSWR<CanvasHistoryMessagesResponse>(
historyMessagesSWRKey,
fetchCanvasHistoryMessages,
{
revalidateOnFocus: false,
revalidateOnReconnect: false,
dedupingInterval: 10000,
}
);
const historyData =
fetchedHistoryData?.chatId === rawChatId ? fetchedHistoryData : undefined;
const initialCanvasMessageCount =
activeChatId === chatId ? initialMessages.length : 0;
const voteSWRKey =
@ -15316,6 +15389,7 @@ export function CanvasChatPanel({
}
forceHistoryHydrationRef.current = toRawCanvasChatId(sessionId);
releaseSendGuard(activeChatId);
activeChatIdRef.current = sessionId;
setActiveChatId(sessionId);
replaceChatSessionQuery(sessionId);
setIsSessionMenuOpen(false);
@ -15365,6 +15439,7 @@ export function CanvasChatPanel({
forceHistoryHydrationRef.current = toRawCanvasChatId(sessionId);
releaseSendGuard(activeChatId);
activeChatIdRef.current = sessionId;
setActiveChatId(sessionId);
setComposerModeOverride("clawd");
setIsDetailGenerationMode(false);

View File

@ -9,6 +9,7 @@ export const MAX_CHAT_ATTACHMENT_NAME_LENGTH = 100;
const CHAT_UUID_REGEX =
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
const CANVAS_CHAT_QUERY_PARAM = "chat";
const ALLOWED_CHAT_MEDIA_TYPES = new Set([
"image/jpeg",
@ -70,6 +71,42 @@ export function isValidChatUuid(value?: string | null): value is string {
return Boolean(value && CHAT_UUID_REGEX.test(String(value).trim()));
}
export function normalizeCanvasChatSessionId(value?: string | null) {
const raw = String(value || "").trim();
if (!raw) return null;
const rawUuid = raw.startsWith("canvas-") ? raw.slice("canvas-".length) : raw;
return isValidChatUuid(rawUuid) ? `canvas-${rawUuid}` : null;
}
export function replaceCanvasChatSessionHistory(params: {
currentUrl: string;
history: Pick<History, "replaceState">;
sessionId: string;
}) {
const normalized = normalizeCanvasChatSessionId(params.sessionId);
if (!normalized) return null;
const url = new URL(params.currentUrl);
url.searchParams.set(CANVAS_CHAT_QUERY_PARAM, normalized);
// Reusing Next.js' existing history state bypasses its URL synchronization.
params.history.replaceState(null, "", url);
return normalized;
}
export function resolveCanvasChatSessionNavigationTarget(params: {
activeSessionId: string;
browserSessionId: string | null;
rootSessionId: string;
routerSessionId: string | null;
}) {
if (params.browserSessionId !== params.routerSessionId) return null;
const targetSessionId = params.routerSessionId || params.rootSessionId;
return targetSessionId !== params.activeSessionId ? targetSessionId : null;
}
export function isValidChatHttpUrl(value?: string | null): value is string {
if (!value) return false;
try {

View File

@ -237,7 +237,10 @@ import {
} from "@/components/canvas/chat-panel/canvas-upload-helpers";
import {
mergeClawdHistoryIntoCanvasSessions,
normalizeCanvasChatSessionId,
normalizeChatMediaType,
replaceCanvasChatSessionHistory,
resolveCanvasChatSessionNavigationTarget,
sanitizeChatAttachment,
type PickedObjectAttachment,
} from "@/components/canvas/chat-panel/canvas-chat-session";
@ -258,6 +261,55 @@ function dbTextMessage(
}
test.describe("canvas chat pure helpers", () => {
test("updates the chat query without reusing Next.js internal history state", () => {
const calls: Array<{ data: unknown; url: string | URL | null }> = [];
const sessionId = "canvas-44444444-4444-4444-8444-444444444444";
const history = {
replaceState(data: unknown, _unused: string, url?: string | URL | null) {
calls.push({ data, url: url ?? null });
},
} as Pick<History, "replaceState">;
expect(
replaceCanvasChatSessionHistory({
currentUrl: "https://ai.yydd.ai/canvas/project-id?mode=agent",
history,
sessionId,
})
).toBe(sessionId);
expect(calls).toHaveLength(1);
expect(calls[0]?.data).toBeNull();
const nextUrl = new URL(String(calls[0]?.url));
expect(nextUrl.searchParams.get("mode")).toBe("agent");
expect(nextUrl.searchParams.get("chat")).toBe(sessionId);
expect(normalizeCanvasChatSessionId(nextUrl.searchParams.get("chat"))).toBe(
sessionId
);
});
test("ignores stale router chat state until it matches the browser URL", () => {
const oldSessionId = "canvas-55555555-5555-4555-8555-555555555555";
const newSessionId = "canvas-66666666-6666-4666-8666-666666666666";
expect(
resolveCanvasChatSessionNavigationTarget({
activeSessionId: newSessionId,
browserSessionId: newSessionId,
rootSessionId: oldSessionId,
routerSessionId: oldSessionId,
})
).toBeNull();
expect(
resolveCanvasChatSessionNavigationTarget({
activeSessionId: newSessionId,
browserSessionId: oldSessionId,
rootSessionId: oldSessionId,
routerSessionId: oldSessionId,
})
).toBe(oldSessionId);
});
test("rebuilds Claw session history from persisted database records", () => {
const localSession = {
id: "canvas-11111111-1111-4111-8111-111111111111",