实现会话记录项目隔离
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 { 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,
+41 -5
View File
@@ -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 () => {
+35 -15
View File
@@ -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(