From 6b447eb398c4091247d63549c69603c8dd15a6d2 Mon Sep 17 00:00:00 2001 From: Huarch Date: Fri, 22 May 2026 14:19:14 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E4=BC=9A=E8=AF=9D=E8=AE=B0?= =?UTF-8?q?=E5=BD=95=E5=8F=AF=E8=83=BD=E5=AD=98=E5=82=A8=E4=B8=A4=E6=AC=A1?= =?UTF-8?q?=E7=9A=84bug=EF=BC=9B=E6=9B=B4=E6=94=B9=E4=BC=9A=E8=AF=9D?= =?UTF-8?q?=E8=A1=8C=E4=B8=BA=EF=BC=8C=E9=BB=98=E8=AE=A4=E8=BF=9B=E5=85=A5?= =?UTF-8?q?=E6=96=B0=E5=AF=B9=E8=AF=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/chat/GlobalChatbox.tsx | 16 +++- src/components/chat/chatStorage.test.ts | 64 ++++----------- src/components/chat/chatStorage.ts | 78 ++----------------- .../chat/hooks/useAgentChatSession.test.tsx | 59 +++++++++++++- .../chat/hooks/useAgentChatSession.ts | 49 +++--------- 5 files changed, 103 insertions(+), 163 deletions(-) diff --git a/src/components/chat/GlobalChatbox.tsx b/src/components/chat/GlobalChatbox.tsx index c49a49e..5ecb3e3 100644 --- a/src/components/chat/GlobalChatbox.tsx +++ b/src/components/chat/GlobalChatbox.tsx @@ -32,6 +32,7 @@ export const GlobalChatbox: React.FC = ({ open, onClose }) => { const bottomRef = useRef(null); const inputRef = useRef(null); + const hasResetForOpenRef = useRef(false); const theme = useTheme(); const currentProjectId = useProjectStore((state) => state.currentProjectId); @@ -87,13 +88,22 @@ export const GlobalChatbox: React.FC = ({ open, onClose }) => { }, [messages, isStreaming]); useEffect(() => { - if (!open) return; + if (!open) { + hasResetForOpenRef.current = false; + return; + } + if (hasResetForOpenRef.current || isHydrating) return; + hasResetForOpenRef.current = true; + const timer = window.setTimeout(() => { + createSession(); + setInput(""); + setIsHistoryOpen(false); inputRef.current?.focus(); bottomRef.current?.scrollIntoView({ behavior: "auto" }); }, 0); return () => window.clearTimeout(timer); - }, [open]); + }, [createSession, isHydrating, open]); const handleSend = useCallback(() => { const prompt = input.trim(); @@ -112,7 +122,7 @@ export const GlobalChatbox: React.FC = ({ open, onClose }) => { const handleNewConversation = useCallback(() => { handleStopSpeech(); stopListening(); - void createSession(); + createSession(); setInput(""); window.setTimeout(() => { inputRef.current?.focus(); diff --git a/src/components/chat/chatStorage.test.ts b/src/components/chat/chatStorage.test.ts index 4b3f2b7..ffbef3d 100644 --- a/src/components/chat/chatStorage.test.ts +++ b/src/components/chat/chatStorage.test.ts @@ -1,5 +1,4 @@ import { - createEmptyChatSession, loadActiveChatState, saveActiveChatState, } from "./chatStorage"; @@ -16,33 +15,22 @@ describe("chatStorage backend-only persistence", () => { apiFetch.mockReset(); }); - it("loads the active remote session when localStorage has an active id", async () => { + it("starts from an empty conversation instead of restoring a stored active id", async () => { window.localStorage.setItem("tjwater_agent_active_session_id_v2", "chat-active-1"); - apiFetch.mockImplementation(async (url: string) => { - if (url.endsWith("/api/v1/agent/chat/session/chat-active-1")) { - return { - ok: true, - json: async () => ({ - id: "chat-active-1", - title: "已存在会话", - is_title_manually_edited: false, - session_id: "chat-active-1", - messages: [], - branch_groups: [], - }), - } as Response; - } - throw new Error(`Unexpected request ${url}`); - }); - const loaded = await loadActiveChatState(); - expect(loaded.storageSessionId).toBe("chat-active-1"); - expect(loaded.title).toBe("已存在会话"); + expect(loaded).toMatchObject({ + storageSessionId: undefined, + title: undefined, + messages: [], + sessionId: undefined, + branchGroups: [], + }); + expect(apiFetch).not.toHaveBeenCalled(); }); - it("loads the active remote session from the current project's storage key", async () => { + it("starts from an empty conversation when a project has a stored active id", async () => { window.localStorage.setItem( "tjwater_agent_active_session_id_v2:project-a", "chat-project-a", @@ -52,27 +40,12 @@ describe("chatStorage backend-only persistence", () => { "chat-project-b", ); - apiFetch.mockImplementation(async (url: string) => { - if (url.endsWith("/api/v1/agent/chat/session/chat-project-b")) { - return { - ok: true, - json: async () => ({ - id: "chat-project-b", - title: "项目 B 会话", - is_title_manually_edited: false, - session_id: "chat-project-b", - messages: [], - branch_groups: [], - }), - } as Response; - } - throw new Error(`Unexpected request ${url}`); - }); - const loaded = await loadActiveChatState("project-b"); - expect(loaded.storageSessionId).toBe("chat-project-b"); - expect(loaded.title).toBe("项目 B 会话"); + expect(loaded.storageSessionId).toBeUndefined(); + expect(loaded.title).toBeUndefined(); + expect(loaded.messages).toEqual([]); + expect(apiFetch).not.toHaveBeenCalled(); }); it("creates a backend conversation when saving the first non-empty state", async () => { @@ -122,14 +95,7 @@ describe("chatStorage backend-only persistence", () => { expect(savedSessionId).toBe("chat-new-1"); expect( window.localStorage.getItem("tjwater_agent_active_session_id_v2:project-a"), - ).toBe("chat-new-1"); + ).toBeNull(); }); - it("does not persist a blank new session before there is chat content", async () => { - const session = await createEmptyChatSession(); - - expect(session.storageSessionId).toBeUndefined(); - expect(session.title).toBe("新对话"); - expect(apiFetch).not.toHaveBeenCalled(); - }); }); diff --git a/src/components/chat/chatStorage.ts b/src/components/chat/chatStorage.ts index ca1a9de..bda1fbc 100644 --- a/src/components/chat/chatStorage.ts +++ b/src/components/chat/chatStorage.ts @@ -9,8 +9,6 @@ import type { } from "./GlobalChatbox.types"; import { cloneBranchGroups, cloneMessages } from "./GlobalChatbox.utils"; -const ACTIVE_SESSION_STORAGE_KEY = "tjwater_agent_active_session_id_v2"; - type RemoteSessionPayload = { id?: string; title?: string; @@ -60,34 +58,6 @@ const toMillis = (value: string | number | undefined) => const normalizeTitle = (value?: string) => value?.trim() || "新对话"; -const getActiveSessionStorageKey = (projectId?: string | null) => { - const normalizedProjectId = projectId?.trim(); - return normalizedProjectId - ? `${ACTIVE_SESSION_STORAGE_KEY}:${encodeURIComponent(normalizedProjectId)}` - : ACTIVE_SESSION_STORAGE_KEY; -}; - -const getStoredActiveSessionId = (projectId?: string | null) => { - if (typeof window === "undefined") return undefined; - const stored = window.localStorage - .getItem(getActiveSessionStorageKey(projectId)) - ?.trim(); - return stored || undefined; -}; - -const setStoredActiveSessionId = ( - sessionId?: string, - projectId?: string | null, -) => { - if (typeof window === "undefined") return; - const storageKey = getActiveSessionStorageKey(projectId); - if (sessionId) { - window.localStorage.setItem(storageKey, sessionId); - return; - } - window.localStorage.removeItem(storageKey); -}; - const fetchRemoteChatSessions = async (): Promise => { const response = await apiFetch(`${config.AGENT_URL}/api/v1/agent/chat/sessions`, { method: "GET", @@ -247,36 +217,18 @@ const deleteRemoteChatSession = async (sessionId: string) => { }; export const loadActiveChatState = async ( - projectId?: string | null, + _projectId?: string | null, ): Promise => { - if (typeof window === "undefined") return emptyLoadedChatState(); - - const activeSessionId = getStoredActiveSessionId(projectId); - if (activeSessionId) { - const activeSession = await fetchRemoteChatSession(activeSessionId); - if (activeSession.storageSessionId) { - return activeSession; - } - setStoredActiveSessionId(undefined, projectId); - } - - const sessions = await fetchRemoteChatSessions(); - const latestSession = sessions[0]; - if (!latestSession) { - return emptyLoadedChatState(); - } - setStoredActiveSessionId(latestSession.id, projectId); - return await fetchRemoteChatSession(latestSession.id); + return emptyLoadedChatState(); }; export const saveActiveChatState = async ( state: LoadedChatState, - projectId?: string | null, + _projectId?: string | null, ): Promise => { if (typeof window === "undefined") return state.storageSessionId; if (!hasChatContent(state)) { - setStoredActiveSessionId(undefined, projectId); return undefined; } @@ -290,7 +242,6 @@ export const saveActiveChatState = async ( storageSessionId: remoteSessionId, sessionId: remoteSessionId, }); - setStoredActiveSessionId(savedSessionId, projectId); return savedSessionId; }; @@ -317,39 +268,22 @@ export const updateChatSessionTitle = async ( ); }; -export const createEmptyChatSession = async ( - projectId?: string | null, -): Promise => { - if (typeof window === "undefined") return emptyLoadedChatState(); - - setStoredActiveSessionId(undefined, projectId); - return { - ...emptyLoadedChatState(), - title: "新对话", - }; -}; - export const loadChatSessionById = async ( sessionId: string, - projectId?: string | null, + _projectId?: string | null, ): Promise => { if (typeof window === "undefined") return emptyLoadedChatState(); - const loaded = await fetchRemoteChatSession(sessionId); - if (loaded.storageSessionId) { - setStoredActiveSessionId(sessionId, projectId); - } - return loaded; + return await fetchRemoteChatSession(sessionId); }; export const deleteChatSession = async ( sessionId: string, - projectId?: string | null, + _projectId?: string | null, ): Promise => { if (typeof window === "undefined") return undefined; await deleteRemoteChatSession(sessionId); const nextActiveSession = (await listChatSessions())[0]; - setStoredActiveSessionId(nextActiveSession?.id, projectId); return nextActiveSession?.id; }; diff --git a/src/components/chat/hooks/useAgentChatSession.test.tsx b/src/components/chat/hooks/useAgentChatSession.test.tsx index 063ed60..d321c55 100644 --- a/src/components/chat/hooks/useAgentChatSession.test.tsx +++ b/src/components/chat/hooks/useAgentChatSession.test.tsx @@ -4,6 +4,7 @@ import { act, renderHook, waitFor } from "@testing-library/react"; import { useAgentChatSession } from "./useAgentChatSession"; import { streamAgentChat } from "@/lib/chatStream"; +import type { StreamEvent } from "@/lib/chatStream"; jest.mock("@/lib/chatStream", () => ({ abortAgentChat: jest.fn(async () => undefined), @@ -13,6 +14,7 @@ jest.mock("@/lib/chatStream", () => ({ const loadActiveChatState = jest.fn(); const listChatSessions = jest.fn(); +const saveActiveChatState = jest.fn(); const updateChatSessionTitle = jest.fn(); jest.mock("../chatStorage", () => ({ @@ -27,7 +29,7 @@ jest.mock("../chatStorage", () => ({ sessionId: undefined, branchGroups: [], })), - saveActiveChatState: jest.fn(async (state) => state.storageSessionId), + saveActiveChatState: (...args: unknown[]) => saveActiveChatState(...args), updateChatSessionTitle: (...args: unknown[]) => updateChatSessionTitle(...args), })); @@ -35,8 +37,10 @@ describe("useAgentChatSession", () => { beforeEach(() => { loadActiveChatState.mockReset(); listChatSessions.mockReset(); + saveActiveChatState.mockReset(); updateChatSessionTitle.mockReset(); jest.mocked(streamAgentChat).mockReset(); + saveActiveChatState.mockImplementation(async (state) => state.storageSessionId); loadActiveChatState.mockResolvedValue({ storageSessionId: undefined, @@ -105,6 +109,59 @@ describe("useAgentChatSession", () => { ]); }); + it("waits for the stream session id before persisting a new streaming conversation", async () => { + listChatSessions.mockResolvedValue([]); + let emitStreamEvent: ((event: StreamEvent) => void) | undefined; + jest.mocked(streamAgentChat).mockImplementationOnce(async ({ onEvent }) => { + emitStreamEvent = onEvent; + await new Promise(() => undefined); + }); + + const { result } = renderHook(() => + useAgentChatSession({ + projectId: "project-1", + onToolCall: jest.fn(), + }), + ); + + await waitFor(() => expect(result.current.isHydrating).toBe(false)); + + jest.useFakeTimers(); + try { + await act(async () => { + void result.current.sendPrompt("第一条消息"); + await Promise.resolve(); + }); + + expect(result.current.isStreaming).toBe(true); + + await act(async () => { + jest.advanceTimersByTime(200); + }); + + expect(saveActiveChatState).not.toHaveBeenCalled(); + + act(() => { + emitStreamEvent?.({ + type: "token", + sessionId: "chat-stream-1", + content: "收到", + }); + }); + + await act(async () => { + jest.advanceTimersByTime(200); + }); + + expect(saveActiveChatState).toHaveBeenCalledTimes(1); + expect(saveActiveChatState.mock.calls[0][0]).toMatchObject({ + sessionId: "chat-stream-1", + }); + } finally { + jest.useRealTimers(); + } + }); + it("ignores generated session titles after the title was edited manually", async () => { listChatSessions.mockResolvedValue([]); loadActiveChatState.mockResolvedValue({ diff --git a/src/components/chat/hooks/useAgentChatSession.ts b/src/components/chat/hooks/useAgentChatSession.ts index 221ea6b..ac0feb8 100644 --- a/src/components/chat/hooks/useAgentChatSession.ts +++ b/src/components/chat/hooks/useAgentChatSession.ts @@ -269,6 +269,15 @@ export const useAgentChatSession = ({ sessionId, branchGroups, }; + if ( + isStreaming && + !state.storageSessionId && + !state.sessionId && + state.messages.length > 0 + ) { + return; + } + const currentStateKey = createPersistedStateKey(state); if (currentStateKey === lastPersistedStateKeyRef.current) { return; @@ -296,7 +305,7 @@ export const useAgentChatSession = ({ return () => { window.clearTimeout(persistTimer); }; - }, [branchGroups, isHydrating, isSessionTitleManuallyEdited, messages, projectId, sessionId, sessionTitle]); + }, [branchGroups, isHydrating, isSessionTitleManuallyEdited, isStreaming, messages, projectId, sessionId, sessionTitle]); useEffect(() => { setBranchGroups((prev) => { @@ -538,42 +547,7 @@ export const useAgentChatSession = ({ cancelPromiseRef.current = trackedCancelPromise; }, []); - const reset = useCallback(() => { - const controller = abortRef.current; - controller?.abort(); - const activeSessionId = sessionIdRef.current; - if (activeSessionId) { - const cancelPromise = abortAgentChat(activeSessionId).catch((error) => { - console.error("[GlobalChatbox] Failed to abort agent session during reset:", error); - }); - const trackedCancelPromise = cancelPromise.finally(() => { - if (cancelPromiseRef.current === trackedCancelPromise) { - cancelPromiseRef.current = null; - } - }); - cancelPromiseRef.current = trackedCancelPromise; - } - setMessages([]); - setSessionTitle(undefined); - setIsSessionTitleManuallyEdited(false); - setBranchGroups([]); - setBranchTransition(null); - setSessionId(undefined); - sessionIdRef.current = undefined; - storageSessionIdRef.current = undefined; - lastPersistedStateKeyRef.current = createPersistedStateKey({ - storageSessionId: undefined, - title: undefined, - isTitleManuallyEdited: false, - messages: [], - sessionId: undefined, - branchGroups: [], - }); - titleUpdateNonceRef.current += 1; - setIsStreaming(false); - }, []); - - const createSession = useCallback(async () => { + const createSession = useCallback(() => { if (isHydrating || isStreaming) return; const controller = abortRef.current; @@ -903,7 +877,6 @@ export const useAgentChatSession = ({ cycleBranch, abort, createSession, - reset, renameSession, removeSession, switchSession,