实现会话记录项目隔离
Build Push and Deploy / docker-image (push) Successful in 1m13s
Build Push and Deploy / deploy-fallback-log (push) Has been skipped

This commit is contained in:
2026-05-22 11:20:06 +08:00
parent 4bf99e8069
commit 54fbf15be8
5 changed files with 138 additions and 43 deletions
+3
View File
@@ -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,
+54 -18
View File
@@ -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 () => {
+35 -15
View File
@@ -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(