修复会话记录可能存储两次的bug;更改会话行为,默认进入新对话
Build Push and Deploy / docker-image (push) Successful in 1m1s
Build Push and Deploy / deploy-fallback-log (push) Has been skipped

This commit is contained in:
2026-05-22 14:19:14 +08:00
parent 54fbf15be8
commit 6b447eb398
5 changed files with 103 additions and 163 deletions
+13 -3
View File
@@ -32,6 +32,7 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
const bottomRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement | null>(null);
const hasResetForOpenRef = useRef(false);
const theme = useTheme();
const currentProjectId = useProjectStore((state) => state.currentProjectId);
@@ -87,13 +88,22 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
}, [messages, isStreaming]);
useEffect(() => {
if (!open) return;
if (!open) {
hasResetForOpenRef.current = false;
return;
}
if (hasResetForOpenRef.current || isHydrating) return;
hasResetForOpenRef.current = true;
const timer = window.setTimeout(() => {
createSession();
setInput("");
setIsHistoryOpen(false);
inputRef.current?.focus();
bottomRef.current?.scrollIntoView({ behavior: "auto" });
}, 0);
return () => window.clearTimeout(timer);
}, [open]);
}, [createSession, isHydrating, open]);
const handleSend = useCallback(() => {
const prompt = input.trim();
@@ -112,7 +122,7 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
const handleNewConversation = useCallback(() => {
handleStopSpeech();
stopListening();
void createSession();
createSession();
setInput("");
window.setTimeout(() => {
inputRef.current?.focus();
+15 -49
View File
@@ -1,5 +1,4 @@
import {
createEmptyChatSession,
loadActiveChatState,
saveActiveChatState,
} from "./chatStorage";
@@ -16,33 +15,22 @@ describe("chatStorage backend-only persistence", () => {
apiFetch.mockReset();
});
it("loads the active remote session when localStorage has an active id", async () => {
it("starts from an empty conversation instead of restoring a stored active id", async () => {
window.localStorage.setItem("tjwater_agent_active_session_id_v2", "chat-active-1");
apiFetch.mockImplementation(async (url: string) => {
if (url.endsWith("/api/v1/agent/chat/session/chat-active-1")) {
return {
ok: true,
json: async () => ({
id: "chat-active-1",
title: "已存在会话",
is_title_manually_edited: false,
session_id: "chat-active-1",
messages: [],
branch_groups: [],
}),
} as Response;
}
throw new Error(`Unexpected request ${url}`);
});
const loaded = await loadActiveChatState();
expect(loaded.storageSessionId).toBe("chat-active-1");
expect(loaded.title).toBe("已存在会话");
expect(loaded).toMatchObject({
storageSessionId: undefined,
title: undefined,
messages: [],
sessionId: undefined,
branchGroups: [],
});
expect(apiFetch).not.toHaveBeenCalled();
});
it("loads the active remote session from the current project's storage key", async () => {
it("starts from an empty conversation when a project has a stored active id", async () => {
window.localStorage.setItem(
"tjwater_agent_active_session_id_v2:project-a",
"chat-project-a",
@@ -52,27 +40,12 @@ describe("chatStorage backend-only persistence", () => {
"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 会话");
expect(loaded.storageSessionId).toBeUndefined();
expect(loaded.title).toBeUndefined();
expect(loaded.messages).toEqual([]);
expect(apiFetch).not.toHaveBeenCalled();
});
it("creates a backend conversation when saving the first non-empty state", async () => {
@@ -122,14 +95,7 @@ describe("chatStorage backend-only persistence", () => {
expect(savedSessionId).toBe("chat-new-1");
expect(
window.localStorage.getItem("tjwater_agent_active_session_id_v2:project-a"),
).toBe("chat-new-1");
).toBeNull();
});
it("does not persist a blank new session before there is chat content", async () => {
const session = await createEmptyChatSession();
expect(session.storageSessionId).toBeUndefined();
expect(session.title).toBe("新对话");
expect(apiFetch).not.toHaveBeenCalled();
});
});
+6 -72
View File
@@ -9,8 +9,6 @@ import type {
} from "./GlobalChatbox.types";
import { cloneBranchGroups, cloneMessages } from "./GlobalChatbox.utils";
const ACTIVE_SESSION_STORAGE_KEY = "tjwater_agent_active_session_id_v2";
type RemoteSessionPayload = {
id?: string;
title?: string;
@@ -60,34 +58,6 @@ const toMillis = (value: string | number | undefined) =>
const normalizeTitle = (value?: string) => value?.trim() || "新对话";
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(getActiveSessionStorageKey(projectId))
?.trim();
return stored || undefined;
};
const setStoredActiveSessionId = (
sessionId?: string,
projectId?: string | null,
) => {
if (typeof window === "undefined") return;
const storageKey = getActiveSessionStorageKey(projectId);
if (sessionId) {
window.localStorage.setItem(storageKey, sessionId);
return;
}
window.localStorage.removeItem(storageKey);
};
const fetchRemoteChatSessions = async (): Promise<ChatSessionSummary[]> => {
const response = await apiFetch(`${config.AGENT_URL}/api/v1/agent/chat/sessions`, {
method: "GET",
@@ -247,36 +217,18 @@ const deleteRemoteChatSession = async (sessionId: string) => {
};
export const loadActiveChatState = async (
projectId?: string | null,
_projectId?: string | null,
): Promise<LoadedChatState> => {
if (typeof window === "undefined") return emptyLoadedChatState();
const activeSessionId = getStoredActiveSessionId(projectId);
if (activeSessionId) {
const activeSession = await fetchRemoteChatSession(activeSessionId);
if (activeSession.storageSessionId) {
return activeSession;
}
setStoredActiveSessionId(undefined, projectId);
}
const sessions = await fetchRemoteChatSessions();
const latestSession = sessions[0];
if (!latestSession) {
return emptyLoadedChatState();
}
setStoredActiveSessionId(latestSession.id, projectId);
return await fetchRemoteChatSession(latestSession.id);
return emptyLoadedChatState();
};
export const saveActiveChatState = async (
state: LoadedChatState,
projectId?: string | null,
_projectId?: string | null,
): Promise<string | undefined> => {
if (typeof window === "undefined") return state.storageSessionId;
if (!hasChatContent(state)) {
setStoredActiveSessionId(undefined, projectId);
return undefined;
}
@@ -290,7 +242,6 @@ export const saveActiveChatState = async (
storageSessionId: remoteSessionId,
sessionId: remoteSessionId,
});
setStoredActiveSessionId(savedSessionId, projectId);
return savedSessionId;
};
@@ -317,39 +268,22 @@ export const updateChatSessionTitle = async (
);
};
export const createEmptyChatSession = async (
projectId?: string | null,
): Promise<LoadedChatState> => {
if (typeof window === "undefined") return emptyLoadedChatState();
setStoredActiveSessionId(undefined, projectId);
return {
...emptyLoadedChatState(),
title: "新对话",
};
};
export const loadChatSessionById = async (
sessionId: string,
projectId?: string | null,
_projectId?: string | null,
): Promise<LoadedChatState> => {
if (typeof window === "undefined") return emptyLoadedChatState();
const loaded = await fetchRemoteChatSession(sessionId);
if (loaded.storageSessionId) {
setStoredActiveSessionId(sessionId, projectId);
}
return loaded;
return await fetchRemoteChatSession(sessionId);
};
export const deleteChatSession = async (
sessionId: string,
projectId?: string | null,
_projectId?: string | null,
): Promise<string | undefined> => {
if (typeof window === "undefined") return undefined;
await deleteRemoteChatSession(sessionId);
const nextActiveSession = (await listChatSessions())[0];
setStoredActiveSessionId(nextActiveSession?.id, projectId);
return nextActiveSession?.id;
};
@@ -4,6 +4,7 @@ import { act, renderHook, waitFor } from "@testing-library/react";
import { useAgentChatSession } from "./useAgentChatSession";
import { streamAgentChat } from "@/lib/chatStream";
import type { StreamEvent } from "@/lib/chatStream";
jest.mock("@/lib/chatStream", () => ({
abortAgentChat: jest.fn(async () => undefined),
@@ -13,6 +14,7 @@ jest.mock("@/lib/chatStream", () => ({
const loadActiveChatState = jest.fn();
const listChatSessions = jest.fn();
const saveActiveChatState = jest.fn();
const updateChatSessionTitle = jest.fn();
jest.mock("../chatStorage", () => ({
@@ -27,7 +29,7 @@ jest.mock("../chatStorage", () => ({
sessionId: undefined,
branchGroups: [],
})),
saveActiveChatState: jest.fn(async (state) => state.storageSessionId),
saveActiveChatState: (...args: unknown[]) => saveActiveChatState(...args),
updateChatSessionTitle: (...args: unknown[]) => updateChatSessionTitle(...args),
}));
@@ -35,8 +37,10 @@ describe("useAgentChatSession", () => {
beforeEach(() => {
loadActiveChatState.mockReset();
listChatSessions.mockReset();
saveActiveChatState.mockReset();
updateChatSessionTitle.mockReset();
jest.mocked(streamAgentChat).mockReset();
saveActiveChatState.mockImplementation(async (state) => state.storageSessionId);
loadActiveChatState.mockResolvedValue({
storageSessionId: undefined,
@@ -105,6 +109,59 @@ describe("useAgentChatSession", () => {
]);
});
it("waits for the stream session id before persisting a new streaming conversation", async () => {
listChatSessions.mockResolvedValue([]);
let emitStreamEvent: ((event: StreamEvent) => void) | undefined;
jest.mocked(streamAgentChat).mockImplementationOnce(async ({ onEvent }) => {
emitStreamEvent = onEvent;
await new Promise<void>(() => undefined);
});
const { result } = renderHook(() =>
useAgentChatSession({
projectId: "project-1",
onToolCall: jest.fn(),
}),
);
await waitFor(() => expect(result.current.isHydrating).toBe(false));
jest.useFakeTimers();
try {
await act(async () => {
void result.current.sendPrompt("第一条消息");
await Promise.resolve();
});
expect(result.current.isStreaming).toBe(true);
await act(async () => {
jest.advanceTimersByTime(200);
});
expect(saveActiveChatState).not.toHaveBeenCalled();
act(() => {
emitStreamEvent?.({
type: "token",
sessionId: "chat-stream-1",
content: "收到",
});
});
await act(async () => {
jest.advanceTimersByTime(200);
});
expect(saveActiveChatState).toHaveBeenCalledTimes(1);
expect(saveActiveChatState.mock.calls[0][0]).toMatchObject({
sessionId: "chat-stream-1",
});
} finally {
jest.useRealTimers();
}
});
it("ignores generated session titles after the title was edited manually", async () => {
listChatSessions.mockResolvedValue([]);
loadActiveChatState.mockResolvedValue({
@@ -269,6 +269,15 @@ export const useAgentChatSession = ({
sessionId,
branchGroups,
};
if (
isStreaming &&
!state.storageSessionId &&
!state.sessionId &&
state.messages.length > 0
) {
return;
}
const currentStateKey = createPersistedStateKey(state);
if (currentStateKey === lastPersistedStateKeyRef.current) {
return;
@@ -296,7 +305,7 @@ export const useAgentChatSession = ({
return () => {
window.clearTimeout(persistTimer);
};
}, [branchGroups, isHydrating, isSessionTitleManuallyEdited, messages, projectId, sessionId, sessionTitle]);
}, [branchGroups, isHydrating, isSessionTitleManuallyEdited, isStreaming, messages, projectId, sessionId, sessionTitle]);
useEffect(() => {
setBranchGroups((prev) => {
@@ -538,42 +547,7 @@ export const useAgentChatSession = ({
cancelPromiseRef.current = trackedCancelPromise;
}, []);
const reset = useCallback(() => {
const controller = abortRef.current;
controller?.abort();
const activeSessionId = sessionIdRef.current;
if (activeSessionId) {
const cancelPromise = abortAgentChat(activeSessionId).catch((error) => {
console.error("[GlobalChatbox] Failed to abort agent session during reset:", error);
});
const trackedCancelPromise = cancelPromise.finally(() => {
if (cancelPromiseRef.current === trackedCancelPromise) {
cancelPromiseRef.current = null;
}
});
cancelPromiseRef.current = trackedCancelPromise;
}
setMessages([]);
setSessionTitle(undefined);
setIsSessionTitleManuallyEdited(false);
setBranchGroups([]);
setBranchTransition(null);
setSessionId(undefined);
sessionIdRef.current = undefined;
storageSessionIdRef.current = undefined;
lastPersistedStateKeyRef.current = createPersistedStateKey({
storageSessionId: undefined,
title: undefined,
isTitleManuallyEdited: false,
messages: [],
sessionId: undefined,
branchGroups: [],
});
titleUpdateNonceRef.current += 1;
setIsStreaming(false);
}, []);
const createSession = useCallback(async () => {
const createSession = useCallback(() => {
if (isHydrating || isStreaming) return;
const controller = abortRef.current;
@@ -903,7 +877,6 @@ export const useAgentChatSession = ({
cycleBranch,
abort,
createSession,
reset,
renameSession,
removeSession,
switchSession,