diff --git a/src/components/chat/GlobalChatbox.tsx b/src/components/chat/GlobalChatbox.tsx index 010405a..c49a49e 100644 --- a/src/components/chat/GlobalChatbox.tsx +++ b/src/components/chat/GlobalChatbox.tsx @@ -9,6 +9,7 @@ import React, { import { Box, Drawer, alpha, useTheme } from "@mui/material"; import type { AgentModel } from "@/lib/chatStream"; +import { useProjectStore } from "@/store/projectStore"; import { AgentComposer } from "./AgentComposer"; import { AgentHeader } from "./AgentHeader"; import { AgentHistoryPanel } from "./AgentHistoryPanel"; @@ -32,6 +33,7 @@ export const GlobalChatbox: React.FC = ({ open, onClose }) => { const bottomRef = useRef(null); const inputRef = useRef(null); const theme = useTheme(); + const currentProjectId = useProjectStore((state) => state.currentProjectId); const { speechState, @@ -74,6 +76,7 @@ export const GlobalChatbox: React.FC = ({ open, onClose }) => { removeSession, switchSession, } = useAgentChatSession({ + projectId: currentProjectId, onToolCall: handleToolCall, onBeforeSend: stopListening, getModel: () => selectedModel, diff --git a/src/components/chat/chatStorage.test.ts b/src/components/chat/chatStorage.test.ts index e602d3b..4b3f2b7 100644 --- a/src/components/chat/chatStorage.test.ts +++ b/src/components/chat/chatStorage.test.ts @@ -42,6 +42,39 @@ describe("chatStorage backend-only persistence", () => { expect(loaded.title).toBe("已存在会话"); }); + it("loads the active remote session from the current project's storage key", async () => { + window.localStorage.setItem( + "tjwater_agent_active_session_id_v2:project-a", + "chat-project-a", + ); + window.localStorage.setItem( + "tjwater_agent_active_session_id_v2:project-b", + "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 会话"); + }); + it("creates a backend conversation when saving the first non-empty state", async () => { apiFetch.mockImplementation(async (url: string, init?: RequestInit) => { if (url.endsWith("/api/v1/agent/chat/session")) { @@ -67,26 +100,29 @@ describe("chatStorage backend-only persistence", () => { throw new Error(`Unexpected request ${url}`); }); - const savedSessionId = await saveActiveChatState({ - storageSessionId: undefined, - title: "新对话", - isTitleManuallyEdited: false, - messages: [ - { - id: "message-2", - role: "user", - content: "第一条消息", - branchRootId: "message-2", - }, - ], - sessionId: undefined, - branchGroups: [], - }); + const savedSessionId = await saveActiveChatState( + { + storageSessionId: undefined, + title: "新对话", + isTitleManuallyEdited: false, + messages: [ + { + id: "message-2", + role: "user", + content: "第一条消息", + branchRootId: "message-2", + }, + ], + sessionId: undefined, + branchGroups: [], + }, + "project-a", + ); expect(savedSessionId).toBe("chat-new-1"); - expect(window.localStorage.getItem("tjwater_agent_active_session_id_v2")).toBe( - "chat-new-1", - ); + expect( + window.localStorage.getItem("tjwater_agent_active_session_id_v2:project-a"), + ).toBe("chat-new-1"); }); it("does not persist a blank new session before there is chat content", async () => { diff --git a/src/components/chat/chatStorage.ts b/src/components/chat/chatStorage.ts index dd2a974..ca1a9de 100644 --- a/src/components/chat/chatStorage.ts +++ b/src/components/chat/chatStorage.ts @@ -60,19 +60,32 @@ const toMillis = (value: string | number | undefined) => const normalizeTitle = (value?: string) => value?.trim() || "新对话"; -const getStoredActiveSessionId = () => { +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(ACTIVE_SESSION_STORAGE_KEY)?.trim(); + const stored = window.localStorage + .getItem(getActiveSessionStorageKey(projectId)) + ?.trim(); return stored || undefined; }; -const setStoredActiveSessionId = (sessionId?: string) => { +const setStoredActiveSessionId = ( + sessionId?: string, + projectId?: string | null, +) => { if (typeof window === "undefined") return; + const storageKey = getActiveSessionStorageKey(projectId); if (sessionId) { - window.localStorage.setItem(ACTIVE_SESSION_STORAGE_KEY, sessionId); + window.localStorage.setItem(storageKey, sessionId); return; } - window.localStorage.removeItem(ACTIVE_SESSION_STORAGE_KEY); + window.localStorage.removeItem(storageKey); }; const fetchRemoteChatSessions = async (): Promise => { @@ -233,16 +246,18 @@ const deleteRemoteChatSession = async (sessionId: string) => { } }; -export const loadActiveChatState = async (): Promise => { +export const loadActiveChatState = async ( + projectId?: string | null, +): Promise => { if (typeof window === "undefined") return emptyLoadedChatState(); - const activeSessionId = getStoredActiveSessionId(); + const activeSessionId = getStoredActiveSessionId(projectId); if (activeSessionId) { const activeSession = await fetchRemoteChatSession(activeSessionId); if (activeSession.storageSessionId) { return activeSession; } - setStoredActiveSessionId(undefined); + setStoredActiveSessionId(undefined, projectId); } const sessions = await fetchRemoteChatSessions(); @@ -250,17 +265,18 @@ export const loadActiveChatState = async (): Promise => { if (!latestSession) { return emptyLoadedChatState(); } - setStoredActiveSessionId(latestSession.id); + setStoredActiveSessionId(latestSession.id, projectId); return await fetchRemoteChatSession(latestSession.id); }; export const saveActiveChatState = async ( state: LoadedChatState, + projectId?: string | null, ): Promise => { if (typeof window === "undefined") return state.storageSessionId; if (!hasChatContent(state)) { - setStoredActiveSessionId(undefined); + setStoredActiveSessionId(undefined, projectId); return undefined; } @@ -274,7 +290,7 @@ export const saveActiveChatState = async ( storageSessionId: remoteSessionId, sessionId: remoteSessionId, }); - setStoredActiveSessionId(savedSessionId); + setStoredActiveSessionId(savedSessionId, projectId); return savedSessionId; }; @@ -301,10 +317,12 @@ export const updateChatSessionTitle = async ( ); }; -export const createEmptyChatSession = async (): Promise => { +export const createEmptyChatSession = async ( + projectId?: string | null, +): Promise => { if (typeof window === "undefined") return emptyLoadedChatState(); - setStoredActiveSessionId(undefined); + setStoredActiveSessionId(undefined, projectId); return { ...emptyLoadedChatState(), title: "新对话", @@ -313,23 +331,25 @@ export const createEmptyChatSession = async (): Promise => { export const loadChatSessionById = async ( sessionId: string, + projectId?: string | null, ): Promise => { if (typeof window === "undefined") return emptyLoadedChatState(); const loaded = await fetchRemoteChatSession(sessionId); if (loaded.storageSessionId) { - setStoredActiveSessionId(sessionId); + setStoredActiveSessionId(sessionId, projectId); } return loaded; }; export const deleteChatSession = async ( sessionId: string, + projectId?: string | null, ): Promise => { if (typeof window === "undefined") return undefined; await deleteRemoteChatSession(sessionId); const nextActiveSession = (await listChatSessions())[0]; - setStoredActiveSessionId(nextActiveSession?.id); + 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 79163da..063ed60 100644 --- a/src/components/chat/hooks/useAgentChatSession.test.tsx +++ b/src/components/chat/hooks/useAgentChatSession.test.tsx @@ -53,6 +53,7 @@ describe("useAgentChatSession", () => { const { result } = renderHook(() => useAgentChatSession({ + projectId: "project-1", onToolCall: jest.fn(), }), ); @@ -83,6 +84,7 @@ describe("useAgentChatSession", () => { const { result } = renderHook(() => useAgentChatSession({ + projectId: "project-1", onToolCall: jest.fn(), }), ); @@ -127,6 +129,7 @@ describe("useAgentChatSession", () => { const { result } = renderHook(() => useAgentChatSession({ + projectId: "project-1", onToolCall: jest.fn(), }), ); diff --git a/src/components/chat/hooks/useAgentChatSession.ts b/src/components/chat/hooks/useAgentChatSession.ts index 883c349..221ea6b 100644 --- a/src/components/chat/hooks/useAgentChatSession.ts +++ b/src/components/chat/hooks/useAgentChatSession.ts @@ -28,6 +28,7 @@ import { } from "../chatStorage"; type UseAgentChatSessionOptions = { + projectId?: string | null; onToolCall: ( event: StreamEvent & { type: "tool_call" }, options: { @@ -145,6 +146,7 @@ const messagesEqual = (left: Message[], right: Message[]) => JSON.stringify(left) === JSON.stringify(right); export const useAgentChatSession = ({ + projectId, onToolCall, onBeforeSend, getModel, @@ -190,9 +192,37 @@ export const useAgentChatSession = ({ let cancelled = false; const hydrate = async () => { + setIsHydrating(true); + hydrationCompletedRef.current = false; + + if (!projectId) { + storageSessionIdRef.current = undefined; + sessionIdRef.current = undefined; + lastPersistedStateKeyRef.current = createPersistedStateKey({ + storageSessionId: undefined, + title: undefined, + isTitleManuallyEdited: false, + messages: [], + sessionId: undefined, + branchGroups: [], + }); + hydrationCompletedRef.current = true; + hydrationNonceRef.current += 1; + titleUpdateNonceRef.current += 1; + setBranchTransition(null); + setMessages([]); + setSessionTitle(undefined); + setIsSessionTitleManuallyEdited(false); + setSessionId(undefined); + setBranchGroups([]); + setChatSessions([]); + setIsHydrating(false); + return; + } + try { const [loadedState, sessions] = await Promise.all([ - loadActiveChatState(), + loadActiveChatState(projectId), listChatSessions(), ]); if (cancelled) return; @@ -224,10 +254,10 @@ export const useAgentChatSession = ({ return () => { cancelled = true; }; - }, []); + }, [projectId]); useEffect(() => { - if (isHydrating || !hydrationCompletedRef.current) return; + if (!projectId || isHydrating || !hydrationCompletedRef.current) return; const currentHydrationNonce = hydrationNonceRef.current; const persistTimer = window.setTimeout(() => { @@ -244,7 +274,7 @@ export const useAgentChatSession = ({ return; } - void saveActiveChatState(state) + void saveActiveChatState(state, projectId) .then((storageSessionId) => { if (hydrationNonceRef.current !== currentHydrationNonce) return; storageSessionIdRef.current = storageSessionId; @@ -266,7 +296,7 @@ export const useAgentChatSession = ({ return () => { window.clearTimeout(persistTimer); }; - }, [branchGroups, isHydrating, isSessionTitleManuallyEdited, messages, sessionId, sessionTitle]); + }, [branchGroups, isHydrating, isSessionTitleManuallyEdited, messages, projectId, sessionId, sessionTitle]); useEffect(() => { setBranchGroups((prev) => { @@ -578,7 +608,7 @@ export const useAgentChatSession = ({ setIsHydrating(true); try { const [nextState, sessions] = await Promise.all([ - loadChatSessionById(nextStorageSessionId), + loadChatSessionById(nextStorageSessionId, projectId), listChatSessions(), ]); @@ -600,7 +630,7 @@ export const useAgentChatSession = ({ setIsHydrating(false); } }, - [isHydrating, isStreaming], + [isHydrating, isStreaming, projectId], ); const removeSession = useCallback( @@ -608,7 +638,10 @@ export const useAgentChatSession = ({ if (isHydrating || isStreaming) return; try { - const nextActiveSessionId = await deleteChatSession(targetStorageSessionId); + const nextActiveSessionId = await deleteChatSession( + targetStorageSessionId, + projectId, + ); const sessions = await listChatSessions(); setChatSessions(sessions); @@ -640,7 +673,7 @@ export const useAgentChatSession = ({ setIsHydrating(true); const [nextState, sessionsAfterDelete] = await Promise.all([ - loadChatSessionById(nextActiveSessionId), + loadChatSessionById(nextActiveSessionId, projectId), listChatSessions(), ]); hydrationNonceRef.current += 1; @@ -661,7 +694,7 @@ export const useAgentChatSession = ({ setIsHydrating(false); } }, - [isHydrating, isStreaming], + [isHydrating, isStreaming, projectId], ); const sendPrompt = useCallback(