实现会话记录项目隔离
This commit is contained in:
@@ -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<Props> = ({ open, onClose }) => {
|
||||
const bottomRef = useRef<HTMLDivElement>(null);
|
||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||
const theme = useTheme();
|
||||
const currentProjectId = useProjectStore((state) => state.currentProjectId);
|
||||
|
||||
const {
|
||||
speechState,
|
||||
@@ -74,6 +76,7 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
|
||||
removeSession,
|
||||
switchSession,
|
||||
} = useAgentChatSession({
|
||||
projectId: currentProjectId,
|
||||
onToolCall: handleToolCall,
|
||||
onBeforeSend: stopListening,
|
||||
getModel: () => selectedModel,
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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<ChatSessionSummary[]> => {
|
||||
@@ -233,16 +246,18 @@ const deleteRemoteChatSession = async (sessionId: string) => {
|
||||
}
|
||||
};
|
||||
|
||||
export const loadActiveChatState = async (): Promise<LoadedChatState> => {
|
||||
export const loadActiveChatState = async (
|
||||
projectId?: string | null,
|
||||
): Promise<LoadedChatState> => {
|
||||
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<LoadedChatState> => {
|
||||
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<string | undefined> => {
|
||||
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<LoadedChatState> => {
|
||||
export const createEmptyChatSession = async (
|
||||
projectId?: string | null,
|
||||
): Promise<LoadedChatState> => {
|
||||
if (typeof window === "undefined") return emptyLoadedChatState();
|
||||
|
||||
setStoredActiveSessionId(undefined);
|
||||
setStoredActiveSessionId(undefined, projectId);
|
||||
return {
|
||||
...emptyLoadedChatState(),
|
||||
title: "新对话",
|
||||
@@ -313,23 +331,25 @@ export const createEmptyChatSession = async (): Promise<LoadedChatState> => {
|
||||
|
||||
export const loadChatSessionById = async (
|
||||
sessionId: string,
|
||||
projectId?: string | null,
|
||||
): Promise<LoadedChatState> => {
|
||||
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<string | undefined> => {
|
||||
if (typeof window === "undefined") return undefined;
|
||||
|
||||
await deleteRemoteChatSession(sessionId);
|
||||
const nextActiveSession = (await listChatSessions())[0];
|
||||
setStoredActiveSessionId(nextActiveSession?.id);
|
||||
setStoredActiveSessionId(nextActiveSession?.id, projectId);
|
||||
return nextActiveSession?.id;
|
||||
};
|
||||
|
||||
@@ -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(),
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user