refactor: use backend chat sessions

This commit is contained in:
2026-06-04 15:02:27 +08:00
parent 20ca410e0a
commit e60e1f6453
9 changed files with 91 additions and 178 deletions
@@ -165,9 +165,6 @@ export const AgentHistoryPanel = ({
<Typography variant="subtitle2" fontWeight={800} color="text.primary"> <Typography variant="subtitle2" fontWeight={800} color="text.primary">
</Typography> </Typography>
<Typography variant="caption" color="text.secondary">
</Typography>
</Box> </Box>
<Tooltip title="新建对话"> <Tooltip title="新建对话">
<motion.div whileHover={{ scale: 1.08 }} whileTap={{ scale: 0.92 }} style={{ display: "flex" }}> <motion.div whileHover={{ scale: 1.08 }} whileTap={{ scale: 0.92 }} style={{ display: "flex" }}>
+12 -12
View File
@@ -60,7 +60,7 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
const { const {
messages, messages,
chatSessions, chatSessions,
activeStorageSessionId, activeSessionId,
branchGroups, branchGroups,
branchTransition, branchTransition,
isHydrating, isHydrating,
@@ -129,33 +129,33 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
}, []); }, []);
const handleSelectSession = useCallback( const handleSelectSession = useCallback(
(storageSessionId: string) => { (sessionId: string) => {
composerRef.current?.clear(); composerRef.current?.clear();
void switchSession(storageSessionId); void switchSession(sessionId);
}, },
[switchSession], [switchSession],
); );
const handleDeleteSession = useCallback( const handleDeleteSession = useCallback(
(storageSessionId: string) => { (sessionId: string) => {
void removeSession(storageSessionId); void removeSession(sessionId);
}, },
[removeSession], [removeSession],
); );
const handleRenameSession = useCallback( const handleRenameSession = useCallback(
(storageSessionId: string, title: string) => { (sessionId: string, title: string) => {
void renameSession(storageSessionId, title); void renameSession(sessionId, title);
}, },
[renameSession], [renameSession],
); );
const handleRenameActiveSession = useCallback( const handleRenameActiveSession = useCallback(
(title: string) => { (title: string) => {
if (!activeStorageSessionId) return; if (!activeSessionId) return;
void renameSession(activeStorageSessionId, title); void renameSession(activeSessionId, title);
}, },
[activeStorageSessionId, renameSession], [activeSessionId, renameSession],
); );
const handleMouseDown = useCallback((event: React.MouseEvent) => { const handleMouseDown = useCallback((event: React.MouseEvent) => {
@@ -255,7 +255,7 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
<AgentHeader <AgentHeader
sessionTitle={sessionTitle} sessionTitle={sessionTitle}
canRenameSessionTitle={Boolean(activeStorageSessionId)} canRenameSessionTitle={Boolean(activeSessionId)}
isHydrating={isHydrating} isHydrating={isHydrating}
isStreaming={isStreaming} isStreaming={isStreaming}
isHistoryOpen={isHistoryOpen} isHistoryOpen={isHistoryOpen}
@@ -294,7 +294,7 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
> >
<AgentHistoryPanel <AgentHistoryPanel
sessions={chatSessions} sessions={chatSessions}
activeSessionId={activeStorageSessionId} activeSessionId={activeSessionId}
isHydrating={isHydrating} isHydrating={isHydrating}
onNewSession={() => { onNewSession={() => {
handleNewConversation(); handleNewConversation();
+1 -25
View File
@@ -66,23 +66,6 @@ export type Props = {
export type SpeechState = "idle" | "playing" | "paused"; export type SpeechState = "idle" | "playing" | "paused";
export type LegacyPersistedChatState = {
messages: Message[];
sessionId?: string;
branchGroups?: BranchGroup[];
};
export type ChatSessionRecord = {
id: string;
title: string;
isTitleManuallyEdited?: boolean;
createdAt: number;
updatedAt: number;
sessionId?: string;
messages: Message[];
branchGroups: BranchGroup[];
};
export type ChatSessionSummary = { export type ChatSessionSummary = {
id: string; id: string;
title: string; title: string;
@@ -90,17 +73,10 @@ export type ChatSessionSummary = {
updatedAt: number; updatedAt: number;
}; };
export type ChatStorageMeta = {
key: "chat-meta";
activeSessionId?: string;
migratedFromLocalStorage?: boolean;
};
export type LoadedChatState = { export type LoadedChatState = {
storageSessionId?: string; sessionId?: string;
title?: string; title?: string;
isTitleManuallyEdited?: boolean; isTitleManuallyEdited?: boolean;
messages: Message[]; messages: Message[];
sessionId?: string;
branchGroups: BranchGroup[]; branchGroups: BranchGroup[];
}; };
+3 -31
View File
@@ -1,5 +1,5 @@
import { import {
loadActiveChatState, createEmptyChatState,
saveActiveChatState, saveActiveChatState,
} from "./chatStorage"; } from "./chatStorage";
@@ -11,17 +11,13 @@ jest.mock("@/lib/apiFetch", () => ({
describe("chatStorage backend-only persistence", () => { describe("chatStorage backend-only persistence", () => {
beforeEach(() => { beforeEach(() => {
window.localStorage.clear();
apiFetch.mockReset(); apiFetch.mockReset();
}); });
it("starts from an empty conversation instead of restoring a stored active id", async () => { it("creates an empty initial conversation state without backend calls", () => {
window.localStorage.setItem("tjwater_agent_active_session_id_v2", "chat-active-1"); const loaded = createEmptyChatState();
const loaded = await loadActiveChatState();
expect(loaded).toMatchObject({ expect(loaded).toMatchObject({
storageSessionId: undefined,
title: undefined, title: undefined,
messages: [], messages: [],
sessionId: undefined, sessionId: undefined,
@@ -30,24 +26,6 @@ describe("chatStorage backend-only persistence", () => {
expect(apiFetch).not.toHaveBeenCalled(); expect(apiFetch).not.toHaveBeenCalled();
}); });
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",
);
window.localStorage.setItem(
"tjwater_agent_active_session_id_v2:project-b",
"chat-project-b",
);
const loaded = await loadActiveChatState("project-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 () => { 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")) {
@@ -75,7 +53,6 @@ describe("chatStorage backend-only persistence", () => {
const savedSessionId = await saveActiveChatState( const savedSessionId = await saveActiveChatState(
{ {
storageSessionId: undefined,
title: "新对话", title: "新对话",
isTitleManuallyEdited: false, isTitleManuallyEdited: false,
messages: [ messages: [
@@ -89,13 +66,8 @@ 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:project-a"),
).toBeNull();
}); });
}); });
+24 -36
View File
@@ -9,15 +9,14 @@ import type {
} from "./GlobalChatbox.types"; } from "./GlobalChatbox.types";
import { cloneBranchGroups, cloneMessages } from "./GlobalChatbox.utils"; import { cloneBranchGroups, cloneMessages } from "./GlobalChatbox.utils";
type RemoteSessionPayload = { type BackendSessionPayload = {
id?: string; id?: string;
title?: string; title?: string;
created_at?: string | number; created_at?: string | number;
updated_at?: string | number; updated_at?: string | number;
}; };
const emptyLoadedChatState = (): LoadedChatState => ({ export const createEmptyChatState = (): LoadedChatState => ({
storageSessionId: undefined,
title: undefined, title: undefined,
isTitleManuallyEdited: false, isTitleManuallyEdited: false,
messages: [], messages: [],
@@ -58,7 +57,7 @@ const toMillis = (value: string | number | undefined) =>
const normalizeTitle = (value?: string) => value?.trim() || "新对话"; const normalizeTitle = (value?: string) => value?.trim() || "新对话";
const fetchRemoteChatSessions = async (): Promise<ChatSessionSummary[]> => { const fetchBackendChatSessions = async (): Promise<ChatSessionSummary[]> => {
const response = await apiFetch(`${config.AGENT_URL}/api/v1/agent/chat/sessions`, { const response = await apiFetch(`${config.AGENT_URL}/api/v1/agent/chat/sessions`, {
method: "GET", method: "GET",
projectHeaderMode: "include", projectHeaderMode: "include",
@@ -69,7 +68,7 @@ const fetchRemoteChatSessions = async (): Promise<ChatSessionSummary[]> => {
throw new Error(await response.text()); throw new Error(await response.text());
} }
const payload = (await response.json()) as { const payload = (await response.json()) as {
sessions?: RemoteSessionPayload[]; sessions?: BackendSessionPayload[];
}; };
return (payload.sessions ?? []) return (payload.sessions ?? [])
.map((session) => ({ .map((session) => ({
@@ -82,7 +81,7 @@ const fetchRemoteChatSessions = async (): Promise<ChatSessionSummary[]> => {
.sort(compareSessionsByAnchorTime); .sort(compareSessionsByAnchorTime);
}; };
const fetchRemoteChatSession = async (sessionId: string): Promise<LoadedChatState> => { const fetchBackendChatSession = async (sessionId: string): Promise<LoadedChatState> => {
const response = await apiFetch( const response = await apiFetch(
`${config.AGENT_URL}/api/v1/agent/chat/session/${encodeURIComponent(sessionId)}`, `${config.AGENT_URL}/api/v1/agent/chat/session/${encodeURIComponent(sessionId)}`,
{ {
@@ -94,7 +93,7 @@ const fetchRemoteChatSession = async (sessionId: string): Promise<LoadedChatStat
); );
if (!response.ok) { if (!response.ok) {
if (response.status === 404) { if (response.status === 404) {
return emptyLoadedChatState(); return createEmptyChatState();
} }
throw new Error(await response.text()); throw new Error(await response.text());
} }
@@ -107,16 +106,15 @@ const fetchRemoteChatSession = async (sessionId: string): Promise<LoadedChatStat
branch_groups?: BranchGroup[]; branch_groups?: BranchGroup[];
}; };
return { return {
storageSessionId: payload.id,
title: normalizeTitle(payload.title), title: normalizeTitle(payload.title),
isTitleManuallyEdited: payload.is_title_manually_edited ?? false, isTitleManuallyEdited: payload.is_title_manually_edited ?? false,
messages: sanitizeMessages(payload.messages), messages: sanitizeMessages(payload.messages),
sessionId: payload.session_id, sessionId: payload.session_id ?? payload.id,
branchGroups: sanitizeBranchGroups(payload.branch_groups), branchGroups: sanitizeBranchGroups(payload.branch_groups),
}; };
}; };
const createRemoteChatSession = async (payload?: { const createBackendChatSession = async (payload?: {
sessionId?: string; sessionId?: string;
parentSessionId?: string; parentSessionId?: string;
}) => { }) => {
@@ -146,7 +144,7 @@ const createRemoteChatSession = async (payload?: {
return sessionId; return sessionId;
}; };
const saveRemoteChatState = async ( const saveBackendChatState = async (
sessionId: string, sessionId: string,
state: LoadedChatState, state: LoadedChatState,
): Promise<string> => { ): Promise<string> => {
@@ -175,7 +173,7 @@ const saveRemoteChatState = async (
return payload.id ?? payload.session_id ?? sessionId; return payload.id ?? payload.session_id ?? sessionId;
}; };
const updateRemoteChatSessionTitle = async ( const updateBackendChatSessionTitle = async (
sessionId: string, sessionId: string,
title: string, title: string,
isTitleManuallyEdited?: boolean, isTitleManuallyEdited?: boolean,
@@ -201,7 +199,7 @@ const updateRemoteChatSessionTitle = async (
} }
}; };
const deleteRemoteChatSession = async (sessionId: string) => { const deleteBackendChatSession = async (sessionId: string) => {
const response = await apiFetch( const response = await apiFetch(
`${config.AGENT_URL}/api/v1/agent/chat/session/${encodeURIComponent(sessionId)}`, `${config.AGENT_URL}/api/v1/agent/chat/session/${encodeURIComponent(sessionId)}`,
{ {
@@ -216,42 +214,34 @@ const deleteRemoteChatSession = async (sessionId: string) => {
} }
}; };
export const loadActiveChatState = async (
_projectId?: string | null,
): Promise<LoadedChatState> => {
return emptyLoadedChatState();
};
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.sessionId;
if (!hasChatContent(state)) { if (!hasChatContent(state)) {
return undefined; return undefined;
} }
let remoteSessionId = state.sessionId ?? state.storageSessionId; let backendSessionId = state.sessionId;
if (!remoteSessionId) { if (!backendSessionId) {
remoteSessionId = await createRemoteChatSession(); backendSessionId = await createBackendChatSession();
} }
const savedSessionId = await saveRemoteChatState(remoteSessionId, { const savedSessionId = await saveBackendChatState(backendSessionId, {
...state, ...state,
storageSessionId: remoteSessionId, sessionId: backendSessionId,
sessionId: remoteSessionId,
}); });
return savedSessionId; return savedSessionId;
}; };
export const listChatSessions = async (): Promise<ChatSessionSummary[]> => { export const listChatSessions = async (): Promise<ChatSessionSummary[]> => {
if (typeof window === "undefined") return []; if (typeof window === "undefined") return [];
return await fetchRemoteChatSessions(); return await fetchBackendChatSessions();
}; };
export const updateChatSessionTitle = async ( export const updateChatSessionTitle = async (
storageSessionId: string, sessionId: string,
title: string, title: string,
options?: { options?: {
isTitleManuallyEdited?: boolean; isTitleManuallyEdited?: boolean;
@@ -261,8 +251,8 @@ export const updateChatSessionTitle = async (
const normalizedTitle = title.trim(); const normalizedTitle = title.trim();
if (!normalizedTitle) return; if (!normalizedTitle) return;
await updateRemoteChatSessionTitle( await updateBackendChatSessionTitle(
storageSessionId, sessionId,
normalizedTitle, normalizedTitle,
options?.isTitleManuallyEdited, options?.isTitleManuallyEdited,
); );
@@ -270,20 +260,18 @@ export const updateChatSessionTitle = async (
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 createEmptyChatState();
return await fetchRemoteChatSession(sessionId); return await fetchBackendChatSession(sessionId);
}; };
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 deleteBackendChatSession(sessionId);
const nextActiveSession = (await listChatSessions())[0]; const nextActiveSession = (await listChatSessions())[0];
return nextActiveSession?.id; return nextActiveSession?.id;
}; };
@@ -12,44 +12,38 @@ jest.mock("@/lib/chatStream", () => ({
streamAgentChat: jest.fn(async () => undefined), streamAgentChat: jest.fn(async () => undefined),
})); }));
const loadActiveChatState = jest.fn();
const listChatSessions = jest.fn(); const listChatSessions = jest.fn();
const saveActiveChatState = jest.fn(); const saveActiveChatState = jest.fn();
const updateChatSessionTitle = jest.fn(); const updateChatSessionTitle = jest.fn();
jest.mock("../chatStorage", () => ({ jest.mock("../chatStorage", () => ({
deleteChatSession: jest.fn(async () => undefined), createEmptyChatState: jest.fn(() => ({
listChatSessions: (...args: unknown[]) => listChatSessions(...args), title: undefined,
loadActiveChatState: (...args: unknown[]) => loadActiveChatState(...args),
loadChatSessionById: jest.fn(async () => ({
storageSessionId: "session-loaded",
title: "已存在会话",
isTitleManuallyEdited: false, isTitleManuallyEdited: false,
messages: [], messages: [],
sessionId: undefined, sessionId: undefined,
branchGroups: [], branchGroups: [],
})), })),
deleteChatSession: jest.fn(async () => undefined),
listChatSessions: (...args: unknown[]) => listChatSessions(...args),
loadChatSessionById: jest.fn(async () => ({
title: "已存在会话",
isTitleManuallyEdited: false,
messages: [],
sessionId: "session-loaded",
branchGroups: [],
})),
saveActiveChatState: (...args: unknown[]) => saveActiveChatState(...args), saveActiveChatState: (...args: unknown[]) => saveActiveChatState(...args),
updateChatSessionTitle: (...args: unknown[]) => updateChatSessionTitle(...args), updateChatSessionTitle: (...args: unknown[]) => updateChatSessionTitle(...args),
})); }));
describe("useAgentChatSession", () => { describe("useAgentChatSession", () => {
beforeEach(() => { beforeEach(() => {
loadActiveChatState.mockReset();
listChatSessions.mockReset(); listChatSessions.mockReset();
saveActiveChatState.mockReset(); saveActiveChatState.mockReset();
updateChatSessionTitle.mockReset(); updateChatSessionTitle.mockReset();
jest.mocked(streamAgentChat).mockReset(); jest.mocked(streamAgentChat).mockReset();
saveActiveChatState.mockImplementation(async (state) => state.storageSessionId); saveActiveChatState.mockImplementation(async (state) => state.sessionId);
loadActiveChatState.mockResolvedValue({
storageSessionId: undefined,
title: undefined,
isTitleManuallyEdited: false,
messages: [],
sessionId: undefined,
branchGroups: [],
});
}); });
it("does not add a new empty session to history until there is actual chat content", async () => { it("does not add a new empty session to history until there is actual chat content", async () => {
@@ -70,7 +64,7 @@ describe("useAgentChatSession", () => {
await waitFor(() => expect(result.current.sessionTitle).toBe("新对话")); await waitFor(() => expect(result.current.sessionTitle).toBe("新对话"));
expect(result.current.chatSessions).toEqual([]); expect(result.current.chatSessions).toEqual([]);
expect(result.current.activeStorageSessionId).toBeUndefined(); expect(result.current.activeSessionId).toBeUndefined();
expect(result.current.messages).toEqual([]); expect(result.current.messages).toEqual([]);
expect(result.current.isStreaming).toBe(false); expect(result.current.isStreaming).toBe(false);
expect(listChatSessions).toHaveBeenCalledTimes(1); expect(listChatSessions).toHaveBeenCalledTimes(1);
@@ -164,14 +158,6 @@ describe("useAgentChatSession", () => {
it("ignores generated session titles after the title was edited manually", async () => { it("ignores generated session titles after the title was edited manually", async () => {
listChatSessions.mockResolvedValue([]); listChatSessions.mockResolvedValue([]);
loadActiveChatState.mockResolvedValue({
storageSessionId: "session-1",
title: "手动标题",
isTitleManuallyEdited: true,
messages: [],
sessionId: "session-1",
branchGroups: [],
});
jest.mocked(streamAgentChat).mockImplementationOnce(async ({ onEvent }) => { jest.mocked(streamAgentChat).mockImplementationOnce(async ({ onEvent }) => {
onEvent({ onEvent({
type: "session_title", type: "session_title",
@@ -193,13 +179,23 @@ describe("useAgentChatSession", () => {
await waitFor(() => expect(result.current.isHydrating).toBe(false)); await waitFor(() => expect(result.current.isHydrating).toBe(false));
await act(async () => {
await result.current.switchSession("session-loaded");
});
await act(async () => {
await result.current.renameSession("session-loaded", "手动标题");
});
await waitFor(() => expect(updateChatSessionTitle).toHaveBeenCalled());
await act(async () => { await act(async () => {
await result.current.sendPrompt("帮我分析一下"); await result.current.sendPrompt("帮我分析一下");
}); });
expect(result.current.sessionTitle).toBe("手动标题"); expect(result.current.sessionTitle).toBe("手动标题");
expect(updateChatSessionTitle).not.toHaveBeenCalledWith( expect(updateChatSessionTitle).not.toHaveBeenCalledWith(
"session-1", "session-loaded",
"自动标题", "自动标题",
expect.anything(), expect.anything(),
); );
@@ -19,9 +19,9 @@ import {
createId, createId,
} from "../GlobalChatbox.utils"; } from "../GlobalChatbox.utils";
import { import {
createEmptyChatState,
deleteChatSession, deleteChatSession,
listChatSessions, listChatSessions,
loadActiveChatState,
loadChatSessionById, loadChatSessionById,
saveActiveChatState, saveActiveChatState,
updateChatSessionTitle, updateChatSessionTitle,
@@ -50,7 +50,6 @@ type PromptRunOptions = {
const createPersistedStateKey = (state: LoadedChatState) => const createPersistedStateKey = (state: LoadedChatState) =>
JSON.stringify({ JSON.stringify({
storageSessionId: state.storageSessionId ?? null,
title: state.title ?? null, title: state.title ?? null,
isTitleManuallyEdited: state.isTitleManuallyEdited ?? false, isTitleManuallyEdited: state.isTitleManuallyEdited ?? false,
sessionId: state.sessionId ?? null, sessionId: state.sessionId ?? null,
@@ -151,7 +150,6 @@ export const useAgentChatSession = ({
onBeforeSend, onBeforeSend,
getModel, getModel,
}: UseAgentChatSessionOptions) => { }: UseAgentChatSessionOptions) => {
const storageSessionIdRef = useRef<string | undefined>(undefined);
const hydrationCompletedRef = useRef(false); const hydrationCompletedRef = useRef(false);
const hydrationNonceRef = useRef(0); const hydrationNonceRef = useRef(0);
@@ -171,11 +169,10 @@ export const useAgentChatSession = ({
const titleUpdateNonceRef = useRef(0); const titleUpdateNonceRef = useRef(0);
const lastPersistedStateKeyRef = useRef( const lastPersistedStateKeyRef = useRef(
createPersistedStateKey({ createPersistedStateKey({
storageSessionId: undefined, sessionId: undefined,
title: undefined, title: undefined,
isTitleManuallyEdited: false, isTitleManuallyEdited: false,
messages: [], messages: [],
sessionId: undefined,
branchGroups: [], branchGroups: [],
}), }),
); );
@@ -196,10 +193,8 @@ export const useAgentChatSession = ({
hydrationCompletedRef.current = false; hydrationCompletedRef.current = false;
if (!projectId) { if (!projectId) {
storageSessionIdRef.current = undefined;
sessionIdRef.current = undefined; sessionIdRef.current = undefined;
lastPersistedStateKeyRef.current = createPersistedStateKey({ lastPersistedStateKeyRef.current = createPersistedStateKey({
storageSessionId: undefined,
title: undefined, title: undefined,
isTitleManuallyEdited: false, isTitleManuallyEdited: false,
messages: [], messages: [],
@@ -222,12 +217,11 @@ export const useAgentChatSession = ({
try { try {
const [loadedState, sessions] = await Promise.all([ const [loadedState, sessions] = await Promise.all([
loadActiveChatState(projectId), Promise.resolve(createEmptyChatState()),
listChatSessions(), listChatSessions(),
]); ]);
if (cancelled) return; if (cancelled) return;
storageSessionIdRef.current = loadedState.storageSessionId;
sessionIdRef.current = loadedState.sessionId; sessionIdRef.current = loadedState.sessionId;
lastPersistedStateKeyRef.current = createPersistedStateKey(loadedState); lastPersistedStateKeyRef.current = createPersistedStateKey(loadedState);
hydrationCompletedRef.current = true; hydrationCompletedRef.current = true;
@@ -262,7 +256,6 @@ export const useAgentChatSession = ({
const currentHydrationNonce = hydrationNonceRef.current; const currentHydrationNonce = hydrationNonceRef.current;
const persistTimer = window.setTimeout(() => { const persistTimer = window.setTimeout(() => {
const state: LoadedChatState = { const state: LoadedChatState = {
storageSessionId: storageSessionIdRef.current,
title: sessionTitle, title: sessionTitle,
isTitleManuallyEdited: isSessionTitleManuallyEdited, isTitleManuallyEdited: isSessionTitleManuallyEdited,
messages, messages,
@@ -271,7 +264,6 @@ export const useAgentChatSession = ({
}; };
if ( if (
isStreaming && isStreaming &&
!state.storageSessionId &&
!state.sessionId && !state.sessionId &&
state.messages.length > 0 state.messages.length > 0
) { ) {
@@ -283,13 +275,13 @@ export const useAgentChatSession = ({
return; return;
} }
void saveActiveChatState(state, projectId) void saveActiveChatState(state)
.then((storageSessionId) => { .then((sessionId) => {
if (hydrationNonceRef.current !== currentHydrationNonce) return; if (hydrationNonceRef.current !== currentHydrationNonce) return;
storageSessionIdRef.current = storageSessionId; sessionIdRef.current = sessionId;
lastPersistedStateKeyRef.current = createPersistedStateKey({ lastPersistedStateKeyRef.current = createPersistedStateKey({
...state, ...state,
storageSessionId, sessionId,
}); });
return listChatSessions(); return listChatSessions();
}) })
@@ -431,10 +423,10 @@ export const useAgentChatSession = ({
const nextTitle = event.title.trim(); const nextTitle = event.title.trim();
if (nextTitle && !isSessionTitleManuallyEditedRef.current) { if (nextTitle && !isSessionTitleManuallyEditedRef.current) {
setSessionTitle(nextTitle); setSessionTitle(nextTitle);
const currentStorageSessionId = storageSessionIdRef.current; const currentSessionId = sessionIdRef.current;
if (currentStorageSessionId) { if (currentSessionId) {
const currentNonce = ++titleUpdateNonceRef.current; const currentNonce = ++titleUpdateNonceRef.current;
void updateChatSessionTitle(currentStorageSessionId, nextTitle, { void updateChatSessionTitle(currentSessionId, nextTitle, {
isTitleManuallyEdited: false, isTitleManuallyEdited: false,
}) })
.then(() => listChatSessions()) .then(() => listChatSessions())
@@ -555,10 +547,8 @@ export const useAgentChatSession = ({
setBranchTransition(null); setBranchTransition(null);
hydrationNonceRef.current += 1; hydrationNonceRef.current += 1;
titleUpdateNonceRef.current += 1; titleUpdateNonceRef.current += 1;
storageSessionIdRef.current = undefined;
sessionIdRef.current = undefined; sessionIdRef.current = undefined;
lastPersistedStateKeyRef.current = createPersistedStateKey({ lastPersistedStateKeyRef.current = createPersistedStateKey({
storageSessionId: undefined,
title: "新对话", title: "新对话",
isTitleManuallyEdited: false, isTitleManuallyEdited: false,
messages: [], messages: [],
@@ -574,21 +564,20 @@ export const useAgentChatSession = ({
}, [isHydrating, isStreaming]); }, [isHydrating, isStreaming]);
const switchSession = useCallback( const switchSession = useCallback(
async (nextStorageSessionId: string) => { async (nextSessionId: string) => {
if (isHydrating || isStreaming || storageSessionIdRef.current === nextStorageSessionId) { if (isHydrating || isStreaming || sessionIdRef.current === nextSessionId) {
return; return;
} }
setIsHydrating(true); setIsHydrating(true);
try { try {
const [nextState, sessions] = await Promise.all([ const [nextState, sessions] = await Promise.all([
loadChatSessionById(nextStorageSessionId, projectId), loadChatSessionById(nextSessionId),
listChatSessions(), listChatSessions(),
]); ]);
hydrationNonceRef.current += 1; hydrationNonceRef.current += 1;
titleUpdateNonceRef.current += 1; titleUpdateNonceRef.current += 1;
storageSessionIdRef.current = nextState.storageSessionId;
sessionIdRef.current = nextState.sessionId; sessionIdRef.current = nextState.sessionId;
lastPersistedStateKeyRef.current = createPersistedStateKey(nextState); lastPersistedStateKeyRef.current = createPersistedStateKey(nextState);
setBranchTransition(null); setBranchTransition(null);
@@ -604,32 +593,29 @@ export const useAgentChatSession = ({
setIsHydrating(false); setIsHydrating(false);
} }
}, },
[isHydrating, isStreaming, projectId], [isHydrating, isStreaming],
); );
const removeSession = useCallback( const removeSession = useCallback(
async (targetStorageSessionId: string) => { async (targetSessionId: string) => {
if (isHydrating || isStreaming) return; if (isHydrating || isStreaming) return;
try { try {
const nextActiveSessionId = await deleteChatSession( const nextActiveSessionId = await deleteChatSession(
targetStorageSessionId, targetSessionId,
projectId,
); );
const sessions = await listChatSessions(); const sessions = await listChatSessions();
setChatSessions(sessions); setChatSessions(sessions);
if (storageSessionIdRef.current !== targetStorageSessionId) { if (sessionIdRef.current !== targetSessionId) {
return; return;
} }
if (!nextActiveSessionId) { if (!nextActiveSessionId) {
hydrationNonceRef.current += 1; hydrationNonceRef.current += 1;
titleUpdateNonceRef.current += 1; titleUpdateNonceRef.current += 1;
storageSessionIdRef.current = undefined;
sessionIdRef.current = undefined; sessionIdRef.current = undefined;
lastPersistedStateKeyRef.current = createPersistedStateKey({ lastPersistedStateKeyRef.current = createPersistedStateKey({
storageSessionId: undefined,
title: undefined, title: undefined,
isTitleManuallyEdited: false, isTitleManuallyEdited: false,
messages: [], messages: [],
@@ -647,12 +633,11 @@ export const useAgentChatSession = ({
setIsHydrating(true); setIsHydrating(true);
const [nextState, sessionsAfterDelete] = await Promise.all([ const [nextState, sessionsAfterDelete] = await Promise.all([
loadChatSessionById(nextActiveSessionId, projectId), loadChatSessionById(nextActiveSessionId),
listChatSessions(), listChatSessions(),
]); ]);
hydrationNonceRef.current += 1; hydrationNonceRef.current += 1;
titleUpdateNonceRef.current += 1; titleUpdateNonceRef.current += 1;
storageSessionIdRef.current = nextState.storageSessionId;
sessionIdRef.current = nextState.sessionId; sessionIdRef.current = nextState.sessionId;
lastPersistedStateKeyRef.current = createPersistedStateKey(nextState); lastPersistedStateKeyRef.current = createPersistedStateKey(nextState);
setBranchTransition(null); setBranchTransition(null);
@@ -668,7 +653,7 @@ export const useAgentChatSession = ({
setIsHydrating(false); setIsHydrating(false);
} }
}, },
[isHydrating, isStreaming, projectId], [isHydrating, isStreaming],
); );
const sendPrompt = useCallback( const sendPrompt = useCallback(
@@ -679,22 +664,22 @@ export const useAgentChatSession = ({
); );
const renameSession = useCallback( const renameSession = useCallback(
async (targetStorageSessionId: string, nextTitle: string) => { async (targetSessionId: string, nextTitle: string) => {
const normalizedTitle = nextTitle.trim(); const normalizedTitle = nextTitle.trim();
if (!normalizedTitle || isHydrating) return; if (!normalizedTitle || isHydrating) return;
try { try {
await updateChatSessionTitle(targetStorageSessionId, normalizedTitle, { await updateChatSessionTitle(targetSessionId, normalizedTitle, {
isTitleManuallyEdited: true, isTitleManuallyEdited: true,
}); });
const sessions = await listChatSessions(); const sessions = await listChatSessions();
setChatSessions(sessions); setChatSessions(sessions);
if (storageSessionIdRef.current === targetStorageSessionId) { if (sessionIdRef.current === targetSessionId) {
setSessionTitle(normalizedTitle); setSessionTitle(normalizedTitle);
setIsSessionTitleManuallyEdited(true); setIsSessionTitleManuallyEdited(true);
lastPersistedStateKeyRef.current = createPersistedStateKey({ lastPersistedStateKeyRef.current = createPersistedStateKey({
storageSessionId: targetStorageSessionId, sessionId: targetSessionId,
title: normalizedTitle, title: normalizedTitle,
isTitleManuallyEdited: true, isTitleManuallyEdited: true,
messages, messages,
@@ -864,7 +849,7 @@ export const useAgentChatSession = ({
return { return {
messages, messages,
chatSessions, chatSessions,
activeStorageSessionId: storageSessionIdRef.current, activeSessionId: sessionIdRef.current,
branchGroups, branchGroups,
branchTransition, branchTransition,
isHydrating, isHydrating,
+2 -2
View File
@@ -103,11 +103,11 @@ describe("streamAgentChat", () => {
}); });
}); });
it("parses legacy tool_call arguments when params is empty", async () => { it("parses tool_call arguments when params is empty", async () => {
apiFetch.mockResolvedValue({ apiFetch.mockResolvedValue({
ok: true, ok: true,
body: makeStream([ body: makeStream([
'event: tool_call\ndata: {"conversationId":"agent-1e75dd01-29e","tool":"locate_features","params":{},"arguments":"{\\"ids\\":[\\"142902\\"],\\"feature_type\\":\\"junction\\"}"}\n\n', 'event: tool_call\ndata: {"session_id":"agent-1e75dd01-29e","tool":"locate_features","params":{},"arguments":"{\\"ids\\":[\\"142902\\"],\\"feature_type\\":\\"junction\\"}"}\n\n',
'event: done\ndata: {"session_id":"agent-1e75dd01-29e"}\n\n', 'event: done\ndata: {"session_id":"agent-1e75dd01-29e"}\n\n',
]), ]),
}); });
+1 -2
View File
@@ -163,7 +163,6 @@ export const streamAgentChat = async ({
try { try {
const parsed = JSON.parse(data) as { const parsed = JSON.parse(data) as {
session_id?: string; session_id?: string;
conversationId?: string;
content?: string; content?: string;
message?: string; message?: string;
detail?: string; detail?: string;
@@ -223,7 +222,7 @@ export const streamAgentChat = async ({
} else if (event === "tool_call") { } else if (event === "tool_call") {
onEvent({ onEvent({
type: "tool_call", type: "tool_call",
sessionId: parsed.session_id ?? parsed.conversationId ?? "", sessionId: parsed.session_id ?? "",
tool: parsed.tool ?? "", tool: parsed.tool ?? "",
params: resolveToolParams(parsed.params, parsed.arguments), params: resolveToolParams(parsed.params, parsed.arguments),
}); });