实现会话记录项目隔离
This commit is contained in:
@@ -9,6 +9,7 @@ import React, {
|
|||||||
import { Box, Drawer, alpha, useTheme } from "@mui/material";
|
import { Box, Drawer, alpha, useTheme } from "@mui/material";
|
||||||
|
|
||||||
import type { AgentModel } from "@/lib/chatStream";
|
import type { AgentModel } from "@/lib/chatStream";
|
||||||
|
import { useProjectStore } from "@/store/projectStore";
|
||||||
import { AgentComposer } from "./AgentComposer";
|
import { AgentComposer } from "./AgentComposer";
|
||||||
import { AgentHeader } from "./AgentHeader";
|
import { AgentHeader } from "./AgentHeader";
|
||||||
import { AgentHistoryPanel } from "./AgentHistoryPanel";
|
import { AgentHistoryPanel } from "./AgentHistoryPanel";
|
||||||
@@ -32,6 +33,7 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
|
|||||||
const bottomRef = useRef<HTMLDivElement>(null);
|
const bottomRef = useRef<HTMLDivElement>(null);
|
||||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
|
const currentProjectId = useProjectStore((state) => state.currentProjectId);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
speechState,
|
speechState,
|
||||||
@@ -74,6 +76,7 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
|
|||||||
removeSession,
|
removeSession,
|
||||||
switchSession,
|
switchSession,
|
||||||
} = useAgentChatSession({
|
} = useAgentChatSession({
|
||||||
|
projectId: currentProjectId,
|
||||||
onToolCall: handleToolCall,
|
onToolCall: handleToolCall,
|
||||||
onBeforeSend: stopListening,
|
onBeforeSend: stopListening,
|
||||||
getModel: () => selectedModel,
|
getModel: () => selectedModel,
|
||||||
|
|||||||
@@ -42,6 +42,39 @@ describe("chatStorage backend-only persistence", () => {
|
|||||||
expect(loaded.title).toBe("已存在会话");
|
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 () => {
|
it("creates a backend conversation when saving the first non-empty state", async () => {
|
||||||
apiFetch.mockImplementation(async (url: string, init?: RequestInit) => {
|
apiFetch.mockImplementation(async (url: string, init?: RequestInit) => {
|
||||||
if (url.endsWith("/api/v1/agent/chat/session")) {
|
if (url.endsWith("/api/v1/agent/chat/session")) {
|
||||||
@@ -67,7 +100,8 @@ describe("chatStorage backend-only persistence", () => {
|
|||||||
throw new Error(`Unexpected request ${url}`);
|
throw new Error(`Unexpected request ${url}`);
|
||||||
});
|
});
|
||||||
|
|
||||||
const savedSessionId = await saveActiveChatState({
|
const savedSessionId = await saveActiveChatState(
|
||||||
|
{
|
||||||
storageSessionId: undefined,
|
storageSessionId: undefined,
|
||||||
title: "新对话",
|
title: "新对话",
|
||||||
isTitleManuallyEdited: false,
|
isTitleManuallyEdited: false,
|
||||||
@@ -81,12 +115,14 @@ describe("chatStorage backend-only persistence", () => {
|
|||||||
],
|
],
|
||||||
sessionId: undefined,
|
sessionId: undefined,
|
||||||
branchGroups: [],
|
branchGroups: [],
|
||||||
});
|
},
|
||||||
|
"project-a",
|
||||||
|
);
|
||||||
|
|
||||||
expect(savedSessionId).toBe("chat-new-1");
|
expect(savedSessionId).toBe("chat-new-1");
|
||||||
expect(window.localStorage.getItem("tjwater_agent_active_session_id_v2")).toBe(
|
expect(
|
||||||
"chat-new-1",
|
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 () => {
|
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 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;
|
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;
|
return stored || undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
const setStoredActiveSessionId = (sessionId?: string) => {
|
const setStoredActiveSessionId = (
|
||||||
|
sessionId?: string,
|
||||||
|
projectId?: string | null,
|
||||||
|
) => {
|
||||||
if (typeof window === "undefined") return;
|
if (typeof window === "undefined") return;
|
||||||
|
const storageKey = getActiveSessionStorageKey(projectId);
|
||||||
if (sessionId) {
|
if (sessionId) {
|
||||||
window.localStorage.setItem(ACTIVE_SESSION_STORAGE_KEY, sessionId);
|
window.localStorage.setItem(storageKey, sessionId);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
window.localStorage.removeItem(ACTIVE_SESSION_STORAGE_KEY);
|
window.localStorage.removeItem(storageKey);
|
||||||
};
|
};
|
||||||
|
|
||||||
const fetchRemoteChatSessions = async (): Promise<ChatSessionSummary[]> => {
|
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();
|
if (typeof window === "undefined") return emptyLoadedChatState();
|
||||||
|
|
||||||
const activeSessionId = getStoredActiveSessionId();
|
const activeSessionId = getStoredActiveSessionId(projectId);
|
||||||
if (activeSessionId) {
|
if (activeSessionId) {
|
||||||
const activeSession = await fetchRemoteChatSession(activeSessionId);
|
const activeSession = await fetchRemoteChatSession(activeSessionId);
|
||||||
if (activeSession.storageSessionId) {
|
if (activeSession.storageSessionId) {
|
||||||
return activeSession;
|
return activeSession;
|
||||||
}
|
}
|
||||||
setStoredActiveSessionId(undefined);
|
setStoredActiveSessionId(undefined, projectId);
|
||||||
}
|
}
|
||||||
|
|
||||||
const sessions = await fetchRemoteChatSessions();
|
const sessions = await fetchRemoteChatSessions();
|
||||||
@@ -250,17 +265,18 @@ export const loadActiveChatState = async (): Promise<LoadedChatState> => {
|
|||||||
if (!latestSession) {
|
if (!latestSession) {
|
||||||
return emptyLoadedChatState();
|
return emptyLoadedChatState();
|
||||||
}
|
}
|
||||||
setStoredActiveSessionId(latestSession.id);
|
setStoredActiveSessionId(latestSession.id, projectId);
|
||||||
return await fetchRemoteChatSession(latestSession.id);
|
return await fetchRemoteChatSession(latestSession.id);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const saveActiveChatState = async (
|
export const saveActiveChatState = async (
|
||||||
state: LoadedChatState,
|
state: LoadedChatState,
|
||||||
|
projectId?: string | null,
|
||||||
): Promise<string | undefined> => {
|
): Promise<string | undefined> => {
|
||||||
if (typeof window === "undefined") return state.storageSessionId;
|
if (typeof window === "undefined") return state.storageSessionId;
|
||||||
|
|
||||||
if (!hasChatContent(state)) {
|
if (!hasChatContent(state)) {
|
||||||
setStoredActiveSessionId(undefined);
|
setStoredActiveSessionId(undefined, projectId);
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -274,7 +290,7 @@ export const saveActiveChatState = async (
|
|||||||
storageSessionId: remoteSessionId,
|
storageSessionId: remoteSessionId,
|
||||||
sessionId: remoteSessionId,
|
sessionId: remoteSessionId,
|
||||||
});
|
});
|
||||||
setStoredActiveSessionId(savedSessionId);
|
setStoredActiveSessionId(savedSessionId, projectId);
|
||||||
return savedSessionId;
|
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();
|
if (typeof window === "undefined") return emptyLoadedChatState();
|
||||||
|
|
||||||
setStoredActiveSessionId(undefined);
|
setStoredActiveSessionId(undefined, projectId);
|
||||||
return {
|
return {
|
||||||
...emptyLoadedChatState(),
|
...emptyLoadedChatState(),
|
||||||
title: "新对话",
|
title: "新对话",
|
||||||
@@ -313,23 +331,25 @@ export const createEmptyChatSession = async (): Promise<LoadedChatState> => {
|
|||||||
|
|
||||||
export const loadChatSessionById = async (
|
export const loadChatSessionById = async (
|
||||||
sessionId: string,
|
sessionId: string,
|
||||||
|
projectId?: string | null,
|
||||||
): Promise<LoadedChatState> => {
|
): Promise<LoadedChatState> => {
|
||||||
if (typeof window === "undefined") return emptyLoadedChatState();
|
if (typeof window === "undefined") return emptyLoadedChatState();
|
||||||
|
|
||||||
const loaded = await fetchRemoteChatSession(sessionId);
|
const loaded = await fetchRemoteChatSession(sessionId);
|
||||||
if (loaded.storageSessionId) {
|
if (loaded.storageSessionId) {
|
||||||
setStoredActiveSessionId(sessionId);
|
setStoredActiveSessionId(sessionId, projectId);
|
||||||
}
|
}
|
||||||
return loaded;
|
return loaded;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const deleteChatSession = async (
|
export const deleteChatSession = async (
|
||||||
sessionId: string,
|
sessionId: string,
|
||||||
|
projectId?: string | null,
|
||||||
): Promise<string | undefined> => {
|
): Promise<string | undefined> => {
|
||||||
if (typeof window === "undefined") return undefined;
|
if (typeof window === "undefined") return undefined;
|
||||||
|
|
||||||
await deleteRemoteChatSession(sessionId);
|
await deleteRemoteChatSession(sessionId);
|
||||||
const nextActiveSession = (await listChatSessions())[0];
|
const nextActiveSession = (await listChatSessions())[0];
|
||||||
setStoredActiveSessionId(nextActiveSession?.id);
|
setStoredActiveSessionId(nextActiveSession?.id, projectId);
|
||||||
return nextActiveSession?.id;
|
return nextActiveSession?.id;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ describe("useAgentChatSession", () => {
|
|||||||
|
|
||||||
const { result } = renderHook(() =>
|
const { result } = renderHook(() =>
|
||||||
useAgentChatSession({
|
useAgentChatSession({
|
||||||
|
projectId: "project-1",
|
||||||
onToolCall: jest.fn(),
|
onToolCall: jest.fn(),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
@@ -83,6 +84,7 @@ describe("useAgentChatSession", () => {
|
|||||||
|
|
||||||
const { result } = renderHook(() =>
|
const { result } = renderHook(() =>
|
||||||
useAgentChatSession({
|
useAgentChatSession({
|
||||||
|
projectId: "project-1",
|
||||||
onToolCall: jest.fn(),
|
onToolCall: jest.fn(),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
@@ -127,6 +129,7 @@ describe("useAgentChatSession", () => {
|
|||||||
|
|
||||||
const { result } = renderHook(() =>
|
const { result } = renderHook(() =>
|
||||||
useAgentChatSession({
|
useAgentChatSession({
|
||||||
|
projectId: "project-1",
|
||||||
onToolCall: jest.fn(),
|
onToolCall: jest.fn(),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import {
|
|||||||
} from "../chatStorage";
|
} from "../chatStorage";
|
||||||
|
|
||||||
type UseAgentChatSessionOptions = {
|
type UseAgentChatSessionOptions = {
|
||||||
|
projectId?: string | null;
|
||||||
onToolCall: (
|
onToolCall: (
|
||||||
event: StreamEvent & { type: "tool_call" },
|
event: StreamEvent & { type: "tool_call" },
|
||||||
options: {
|
options: {
|
||||||
@@ -145,6 +146,7 @@ const messagesEqual = (left: Message[], right: Message[]) =>
|
|||||||
JSON.stringify(left) === JSON.stringify(right);
|
JSON.stringify(left) === JSON.stringify(right);
|
||||||
|
|
||||||
export const useAgentChatSession = ({
|
export const useAgentChatSession = ({
|
||||||
|
projectId,
|
||||||
onToolCall,
|
onToolCall,
|
||||||
onBeforeSend,
|
onBeforeSend,
|
||||||
getModel,
|
getModel,
|
||||||
@@ -190,9 +192,37 @@ export const useAgentChatSession = ({
|
|||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
|
|
||||||
const hydrate = async () => {
|
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 {
|
try {
|
||||||
const [loadedState, sessions] = await Promise.all([
|
const [loadedState, sessions] = await Promise.all([
|
||||||
loadActiveChatState(),
|
loadActiveChatState(projectId),
|
||||||
listChatSessions(),
|
listChatSessions(),
|
||||||
]);
|
]);
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
@@ -224,10 +254,10 @@ export const useAgentChatSession = ({
|
|||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
}, []);
|
}, [projectId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isHydrating || !hydrationCompletedRef.current) return;
|
if (!projectId || isHydrating || !hydrationCompletedRef.current) return;
|
||||||
|
|
||||||
const currentHydrationNonce = hydrationNonceRef.current;
|
const currentHydrationNonce = hydrationNonceRef.current;
|
||||||
const persistTimer = window.setTimeout(() => {
|
const persistTimer = window.setTimeout(() => {
|
||||||
@@ -244,7 +274,7 @@ export const useAgentChatSession = ({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
void saveActiveChatState(state)
|
void saveActiveChatState(state, projectId)
|
||||||
.then((storageSessionId) => {
|
.then((storageSessionId) => {
|
||||||
if (hydrationNonceRef.current !== currentHydrationNonce) return;
|
if (hydrationNonceRef.current !== currentHydrationNonce) return;
|
||||||
storageSessionIdRef.current = storageSessionId;
|
storageSessionIdRef.current = storageSessionId;
|
||||||
@@ -266,7 +296,7 @@ export const useAgentChatSession = ({
|
|||||||
return () => {
|
return () => {
|
||||||
window.clearTimeout(persistTimer);
|
window.clearTimeout(persistTimer);
|
||||||
};
|
};
|
||||||
}, [branchGroups, isHydrating, isSessionTitleManuallyEdited, messages, sessionId, sessionTitle]);
|
}, [branchGroups, isHydrating, isSessionTitleManuallyEdited, messages, projectId, sessionId, sessionTitle]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setBranchGroups((prev) => {
|
setBranchGroups((prev) => {
|
||||||
@@ -578,7 +608,7 @@ export const useAgentChatSession = ({
|
|||||||
setIsHydrating(true);
|
setIsHydrating(true);
|
||||||
try {
|
try {
|
||||||
const [nextState, sessions] = await Promise.all([
|
const [nextState, sessions] = await Promise.all([
|
||||||
loadChatSessionById(nextStorageSessionId),
|
loadChatSessionById(nextStorageSessionId, projectId),
|
||||||
listChatSessions(),
|
listChatSessions(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -600,7 +630,7 @@ export const useAgentChatSession = ({
|
|||||||
setIsHydrating(false);
|
setIsHydrating(false);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[isHydrating, isStreaming],
|
[isHydrating, isStreaming, projectId],
|
||||||
);
|
);
|
||||||
|
|
||||||
const removeSession = useCallback(
|
const removeSession = useCallback(
|
||||||
@@ -608,7 +638,10 @@ export const useAgentChatSession = ({
|
|||||||
if (isHydrating || isStreaming) return;
|
if (isHydrating || isStreaming) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const nextActiveSessionId = await deleteChatSession(targetStorageSessionId);
|
const nextActiveSessionId = await deleteChatSession(
|
||||||
|
targetStorageSessionId,
|
||||||
|
projectId,
|
||||||
|
);
|
||||||
const sessions = await listChatSessions();
|
const sessions = await listChatSessions();
|
||||||
setChatSessions(sessions);
|
setChatSessions(sessions);
|
||||||
|
|
||||||
@@ -640,7 +673,7 @@ export const useAgentChatSession = ({
|
|||||||
|
|
||||||
setIsHydrating(true);
|
setIsHydrating(true);
|
||||||
const [nextState, sessionsAfterDelete] = await Promise.all([
|
const [nextState, sessionsAfterDelete] = await Promise.all([
|
||||||
loadChatSessionById(nextActiveSessionId),
|
loadChatSessionById(nextActiveSessionId, projectId),
|
||||||
listChatSessions(),
|
listChatSessions(),
|
||||||
]);
|
]);
|
||||||
hydrationNonceRef.current += 1;
|
hydrationNonceRef.current += 1;
|
||||||
@@ -661,7 +694,7 @@ export const useAgentChatSession = ({
|
|||||||
setIsHydrating(false);
|
setIsHydrating(false);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[isHydrating, isStreaming],
|
[isHydrating, isStreaming, projectId],
|
||||||
);
|
);
|
||||||
|
|
||||||
const sendPrompt = useCallback(
|
const sendPrompt = useCallback(
|
||||||
|
|||||||
Reference in New Issue
Block a user