refactor(chat): remove frontend state saves
This commit is contained in:
@@ -1,6 +1,9 @@
|
|||||||
import {
|
import {
|
||||||
createEmptyChatState,
|
createEmptyChatState,
|
||||||
saveActiveChatState,
|
deleteChatSession,
|
||||||
|
listChatSessions,
|
||||||
|
loadChatSessionById,
|
||||||
|
updateChatSessionTitle,
|
||||||
} from "./chatStorage";
|
} from "./chatStorage";
|
||||||
|
|
||||||
const apiFetch = jest.fn();
|
const apiFetch = jest.fn();
|
||||||
@@ -9,7 +12,7 @@ jest.mock("@/lib/apiFetch", () => ({
|
|||||||
apiFetch: (...args: unknown[]) => apiFetch(...args),
|
apiFetch: (...args: unknown[]) => apiFetch(...args),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
describe("chatStorage backend-only persistence", () => {
|
describe("chatStorage backend session operations", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
apiFetch.mockReset();
|
apiFetch.mockReset();
|
||||||
});
|
});
|
||||||
@@ -25,46 +28,106 @@ describe("chatStorage backend-only persistence", () => {
|
|||||||
expect(apiFetch).not.toHaveBeenCalled();
|
expect(apiFetch).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("creates a backend conversation when saving the first non-empty state", async () => {
|
it("lists backend sessions sorted by created time", async () => {
|
||||||
apiFetch.mockImplementation(async (url: string, init?: RequestInit) => {
|
apiFetch.mockResolvedValueOnce({
|
||||||
if (url.endsWith("/api/v1/agent/chat/session")) {
|
|
||||||
expect(init?.method).toBe("POST");
|
|
||||||
return {
|
|
||||||
ok: true,
|
ok: true,
|
||||||
json: async () => ({ session_id: "chat-new-1" }),
|
json: async () => ({
|
||||||
} as Response;
|
sessions: [
|
||||||
}
|
|
||||||
|
|
||||||
if (url.endsWith("/api/v1/agent/chat/session/chat-new-1")) {
|
|
||||||
expect(init?.method).toBe("PUT");
|
|
||||||
expect(JSON.parse(String(init?.body))).toMatchObject({
|
|
||||||
title: "新对话",
|
|
||||||
is_title_manually_edited: false,
|
|
||||||
});
|
|
||||||
return {
|
|
||||||
ok: true,
|
|
||||||
json: async () => ({ id: "chat-new-1", session_id: "chat-new-1" }),
|
|
||||||
} as Response;
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new Error(`Unexpected request ${url}`);
|
|
||||||
});
|
|
||||||
|
|
||||||
const savedSessionId = await saveActiveChatState(
|
|
||||||
{
|
{
|
||||||
title: "新对话",
|
id: "session-old",
|
||||||
isTitleManuallyEdited: false,
|
title: "旧会话",
|
||||||
messages: [
|
created_at: "2026-01-01T00:00:00.000Z",
|
||||||
|
updated_at: "2026-01-02T00:00:00.000Z",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: "message-2",
|
id: "session-new",
|
||||||
role: "user",
|
title: "新会话",
|
||||||
content: "第一条消息",
|
created_at: "2026-01-03T00:00:00.000Z",
|
||||||
|
updated_at: "2026-01-03T00:00:00.000Z",
|
||||||
|
is_streaming: true,
|
||||||
|
run_status: "running",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
sessionId: undefined,
|
}),
|
||||||
},
|
});
|
||||||
);
|
|
||||||
|
|
||||||
expect(savedSessionId).toBe("chat-new-1");
|
await expect(listChatSessions()).resolves.toEqual([
|
||||||
|
expect.objectContaining({
|
||||||
|
id: "session-new",
|
||||||
|
title: "新会话",
|
||||||
|
isStreaming: true,
|
||||||
|
runStatus: "running",
|
||||||
|
}),
|
||||||
|
expect.objectContaining({
|
||||||
|
id: "session-old",
|
||||||
|
title: "旧会话",
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
expect(apiFetch.mock.calls[0][1]).toMatchObject({ method: "GET" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("loads a backend session state", async () => {
|
||||||
|
apiFetch.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({
|
||||||
|
id: "session-1",
|
||||||
|
title: "管网分析",
|
||||||
|
is_title_manually_edited: true,
|
||||||
|
messages: [{ id: "message-1", role: "user", content: "查压力" }],
|
||||||
|
is_streaming: false,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(loadChatSessionById("session-1")).resolves.toMatchObject({
|
||||||
|
title: "管网分析",
|
||||||
|
isTitleManuallyEdited: true,
|
||||||
|
sessionId: "session-1",
|
||||||
|
messages: [{ id: "message-1", role: "user", content: "查压力" }],
|
||||||
|
});
|
||||||
|
expect(String(apiFetch.mock.calls[0][0])).toContain("/session/session-1");
|
||||||
|
expect(apiFetch.mock.calls[0][1]).toMatchObject({ method: "GET" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("updates a backend session title through the title endpoint", async () => {
|
||||||
|
apiFetch.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
text: async () => "",
|
||||||
|
});
|
||||||
|
|
||||||
|
await updateChatSessionTitle("session-1", " 新标题 ", {
|
||||||
|
isTitleManuallyEdited: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(String(apiFetch.mock.calls[0][0])).toContain("/session/session-1/title");
|
||||||
|
expect(apiFetch.mock.calls[0][1]).toMatchObject({ method: "PATCH" });
|
||||||
|
expect(JSON.parse(String(apiFetch.mock.calls[0][1]?.body))).toEqual({
|
||||||
|
title: "新标题",
|
||||||
|
is_title_manually_edited: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("deletes a backend session and returns the next active session id", async () => {
|
||||||
|
apiFetch
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
text: async () => "",
|
||||||
|
})
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({
|
||||||
|
sessions: [
|
||||||
|
{
|
||||||
|
id: "session-next",
|
||||||
|
title: "下一会话",
|
||||||
|
created_at: "2026-01-01T00:00:00.000Z",
|
||||||
|
updated_at: "2026-01-01T00:00:00.000Z",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(deleteChatSession("session-1")).resolves.toBe("session-next");
|
||||||
|
expect(apiFetch.mock.calls[0][1]).toMatchObject({ method: "DELETE" });
|
||||||
|
expect(apiFetch.mock.calls[1][1]).toMatchObject({ method: "GET" });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -27,13 +27,6 @@ export const createEmptyChatState = (): LoadedChatState => ({
|
|||||||
const sanitizeMessages = (messages: Message[] | undefined) =>
|
const sanitizeMessages = (messages: Message[] | undefined) =>
|
||||||
Array.isArray(messages) ? cloneMessages(messages) : [];
|
Array.isArray(messages) ? cloneMessages(messages) : [];
|
||||||
|
|
||||||
const hasChatContent = (state: {
|
|
||||||
messages: Message[];
|
|
||||||
sessionId?: string;
|
|
||||||
}) =>
|
|
||||||
state.messages.length > 0 ||
|
|
||||||
Boolean(state.sessionId);
|
|
||||||
|
|
||||||
const compareSessionsByAnchorTime = (
|
const compareSessionsByAnchorTime = (
|
||||||
left: Pick<ChatSessionSummary, "id" | "createdAt" | "updatedAt">,
|
left: Pick<ChatSessionSummary, "id" | "createdAt" | "updatedAt">,
|
||||||
right: Pick<ChatSessionSummary, "id" | "createdAt" | "updatedAt">,
|
right: Pick<ChatSessionSummary, "id" | "createdAt" | "updatedAt">,
|
||||||
@@ -113,64 +106,6 @@ const fetchBackendChatSession = async (sessionId: string): Promise<LoadedChatSta
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const createBackendChatSession = async (payload?: {
|
|
||||||
sessionId?: string;
|
|
||||||
parentSessionId?: string;
|
|
||||||
}) => {
|
|
||||||
const response = await apiFetch(`${config.AGENT_URL}/api/v1/agent/chat/session`, {
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
session_id: payload?.sessionId,
|
|
||||||
parent_session_id: payload?.parentSessionId,
|
|
||||||
}),
|
|
||||||
projectHeaderMode: "include",
|
|
||||||
userHeaderMode: "include",
|
|
||||||
skipAuthRedirect: true,
|
|
||||||
});
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(await response.text());
|
|
||||||
}
|
|
||||||
const body = (await response.json()) as {
|
|
||||||
session_id?: string;
|
|
||||||
};
|
|
||||||
const sessionId = body.session_id?.trim();
|
|
||||||
if (!sessionId) {
|
|
||||||
throw new Error("backend did not return session_id");
|
|
||||||
}
|
|
||||||
return sessionId;
|
|
||||||
};
|
|
||||||
|
|
||||||
const saveBackendChatState = async (
|
|
||||||
sessionId: string,
|
|
||||||
state: LoadedChatState,
|
|
||||||
): Promise<string> => {
|
|
||||||
const response = await apiFetch(
|
|
||||||
`${config.AGENT_URL}/api/v1/agent/chat/session/${encodeURIComponent(sessionId)}`,
|
|
||||||
{
|
|
||||||
method: "PUT",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
title: normalizeTitle(state.title),
|
|
||||||
is_title_manually_edited: state.isTitleManuallyEdited ?? false,
|
|
||||||
messages: sanitizeMessages(state.messages),
|
|
||||||
}),
|
|
||||||
projectHeaderMode: "include",
|
|
||||||
userHeaderMode: "include",
|
|
||||||
skipAuthRedirect: true,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(await response.text());
|
|
||||||
}
|
|
||||||
const payload = (await response.json()) as { id?: string; session_id?: string };
|
|
||||||
return payload.id ?? payload.session_id ?? sessionId;
|
|
||||||
};
|
|
||||||
|
|
||||||
const updateBackendChatSessionTitle = async (
|
const updateBackendChatSessionTitle = async (
|
||||||
sessionId: string,
|
sessionId: string,
|
||||||
title: string,
|
title: string,
|
||||||
@@ -212,27 +147,6 @@ const deleteBackendChatSession = async (sessionId: string) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const saveActiveChatState = async (
|
|
||||||
state: LoadedChatState,
|
|
||||||
): Promise<string | undefined> => {
|
|
||||||
if (typeof window === "undefined") return state.sessionId;
|
|
||||||
|
|
||||||
if (!hasChatContent(state)) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
let backendSessionId = state.sessionId;
|
|
||||||
if (!backendSessionId) {
|
|
||||||
backendSessionId = await createBackendChatSession();
|
|
||||||
}
|
|
||||||
|
|
||||||
const savedSessionId = await saveBackendChatState(backendSessionId, {
|
|
||||||
...state,
|
|
||||||
sessionId: backendSessionId,
|
|
||||||
});
|
|
||||||
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 fetchBackendChatSessions();
|
return await fetchBackendChatSessions();
|
||||||
|
|||||||
@@ -7,19 +7,10 @@ import type {
|
|||||||
import type {
|
import type {
|
||||||
AgentPermissionRequest,
|
AgentPermissionRequest,
|
||||||
ChatProgress,
|
ChatProgress,
|
||||||
LoadedChatState,
|
|
||||||
Message,
|
Message,
|
||||||
} from "../GlobalChatbox.types";
|
} from "../GlobalChatbox.types";
|
||||||
import { createId } from "../GlobalChatbox.utils";
|
import { createId } from "../GlobalChatbox.utils";
|
||||||
|
|
||||||
export const createPersistedStateKey = (state: LoadedChatState) =>
|
|
||||||
JSON.stringify({
|
|
||||||
title: state.title ?? null,
|
|
||||||
isTitleManuallyEdited: state.isTitleManuallyEdited ?? false,
|
|
||||||
sessionId: state.sessionId ?? null,
|
|
||||||
messages: state.messages,
|
|
||||||
});
|
|
||||||
|
|
||||||
export const upsertProgress = (
|
export const upsertProgress = (
|
||||||
progress: ChatProgress[] | undefined,
|
progress: ChatProgress[] | undefined,
|
||||||
event: StreamEvent & { type: "progress" },
|
event: StreamEvent & { type: "progress" },
|
||||||
|
|||||||
@@ -24,7 +24,6 @@ jest.mock("@/lib/chatStream", () => ({
|
|||||||
|
|
||||||
const listChatSessions = jest.fn();
|
const listChatSessions = jest.fn();
|
||||||
const deleteChatSession = jest.fn();
|
const deleteChatSession = jest.fn();
|
||||||
const saveActiveChatState = jest.fn();
|
|
||||||
const updateChatSessionTitle = jest.fn();
|
const updateChatSessionTitle = jest.fn();
|
||||||
|
|
||||||
jest.mock("../chatStorage", () => ({
|
jest.mock("../chatStorage", () => ({
|
||||||
@@ -42,7 +41,6 @@ jest.mock("../chatStorage", () => ({
|
|||||||
messages: [],
|
messages: [],
|
||||||
sessionId: "session-loaded",
|
sessionId: "session-loaded",
|
||||||
})),
|
})),
|
||||||
saveActiveChatState: (...args: unknown[]) => saveActiveChatState(...args),
|
|
||||||
updateChatSessionTitle: (...args: unknown[]) => updateChatSessionTitle(...args),
|
updateChatSessionTitle: (...args: unknown[]) => updateChatSessionTitle(...args),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -50,7 +48,6 @@ describe("useAgentChatSession", () => {
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
listChatSessions.mockReset();
|
listChatSessions.mockReset();
|
||||||
deleteChatSession.mockReset();
|
deleteChatSession.mockReset();
|
||||||
saveActiveChatState.mockReset();
|
|
||||||
updateChatSessionTitle.mockReset();
|
updateChatSessionTitle.mockReset();
|
||||||
jest.mocked(abortAgentChat).mockReset();
|
jest.mocked(abortAgentChat).mockReset();
|
||||||
jest.mocked(forkAgentChat).mockReset();
|
jest.mocked(forkAgentChat).mockReset();
|
||||||
@@ -65,7 +62,6 @@ describe("useAgentChatSession", () => {
|
|||||||
jest.mocked(resumeAgentChatStream).mockImplementation(async () => undefined);
|
jest.mocked(resumeAgentChatStream).mockImplementation(async () => undefined);
|
||||||
jest.mocked(streamAgentChat).mockImplementation(async () => undefined);
|
jest.mocked(streamAgentChat).mockImplementation(async () => undefined);
|
||||||
deleteChatSession.mockImplementation(async () => undefined);
|
deleteChatSession.mockImplementation(async () => undefined);
|
||||||
saveActiveChatState.mockImplementation(async (state) => state.sessionId);
|
|
||||||
updateChatSessionTitle.mockImplementation(async () => undefined);
|
updateChatSessionTitle.mockImplementation(async () => undefined);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -24,7 +24,6 @@ jest.mock("@/lib/chatStream", () => ({
|
|||||||
|
|
||||||
const listChatSessions = jest.fn();
|
const listChatSessions = jest.fn();
|
||||||
const deleteChatSession = jest.fn();
|
const deleteChatSession = jest.fn();
|
||||||
const saveActiveChatState = jest.fn();
|
|
||||||
const updateChatSessionTitle = jest.fn();
|
const updateChatSessionTitle = jest.fn();
|
||||||
|
|
||||||
jest.mock("../chatStorage", () => ({
|
jest.mock("../chatStorage", () => ({
|
||||||
@@ -42,7 +41,6 @@ jest.mock("../chatStorage", () => ({
|
|||||||
messages: [],
|
messages: [],
|
||||||
sessionId: "session-loaded",
|
sessionId: "session-loaded",
|
||||||
})),
|
})),
|
||||||
saveActiveChatState: (...args: unknown[]) => saveActiveChatState(...args),
|
|
||||||
updateChatSessionTitle: (...args: unknown[]) => updateChatSessionTitle(...args),
|
updateChatSessionTitle: (...args: unknown[]) => updateChatSessionTitle(...args),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -50,7 +48,6 @@ describe("useAgentChatSession", () => {
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
listChatSessions.mockReset();
|
listChatSessions.mockReset();
|
||||||
deleteChatSession.mockReset();
|
deleteChatSession.mockReset();
|
||||||
saveActiveChatState.mockReset();
|
|
||||||
updateChatSessionTitle.mockReset();
|
updateChatSessionTitle.mockReset();
|
||||||
jest.mocked(abortAgentChat).mockReset();
|
jest.mocked(abortAgentChat).mockReset();
|
||||||
jest.mocked(forkAgentChat).mockReset();
|
jest.mocked(forkAgentChat).mockReset();
|
||||||
@@ -65,7 +62,6 @@ describe("useAgentChatSession", () => {
|
|||||||
jest.mocked(resumeAgentChatStream).mockImplementation(async () => undefined);
|
jest.mocked(resumeAgentChatStream).mockImplementation(async () => undefined);
|
||||||
jest.mocked(streamAgentChat).mockImplementation(async () => undefined);
|
jest.mocked(streamAgentChat).mockImplementation(async () => undefined);
|
||||||
deleteChatSession.mockImplementation(async () => undefined);
|
deleteChatSession.mockImplementation(async () => undefined);
|
||||||
saveActiveChatState.mockImplementation(async (state) => state.sessionId);
|
|
||||||
updateChatSessionTitle.mockImplementation(async () => undefined);
|
updateChatSessionTitle.mockImplementation(async () => undefined);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -190,7 +186,7 @@ describe("useAgentChatSession lifecycle and resume", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("persists a new conversation only after the stream is done", async () => {
|
it("does not autosave full messages after the stream is done", async () => {
|
||||||
listChatSessions.mockResolvedValue([]);
|
listChatSessions.mockResolvedValue([]);
|
||||||
let emitStreamEvent: ((event: StreamEvent) => void) | undefined;
|
let emitStreamEvent: ((event: StreamEvent) => void) | undefined;
|
||||||
jest.mocked(streamAgentChat).mockImplementationOnce(async ({ onEvent }) => {
|
jest.mocked(streamAgentChat).mockImplementationOnce(async ({ onEvent }) => {
|
||||||
@@ -220,8 +216,6 @@ describe("useAgentChatSession lifecycle and resume", () => {
|
|||||||
jest.advanceTimersByTime(200);
|
jest.advanceTimersByTime(200);
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(saveActiveChatState).not.toHaveBeenCalled();
|
|
||||||
|
|
||||||
act(() => {
|
act(() => {
|
||||||
emitStreamEvent?.({
|
emitStreamEvent?.({
|
||||||
type: "token",
|
type: "token",
|
||||||
@@ -234,8 +228,6 @@ describe("useAgentChatSession lifecycle and resume", () => {
|
|||||||
jest.advanceTimersByTime(200);
|
jest.advanceTimersByTime(200);
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(saveActiveChatState).not.toHaveBeenCalled();
|
|
||||||
|
|
||||||
act(() => {
|
act(() => {
|
||||||
emitStreamEvent?.({
|
emitStreamEvent?.({
|
||||||
type: "done",
|
type: "done",
|
||||||
@@ -247,14 +239,11 @@ describe("useAgentChatSession lifecycle and resume", () => {
|
|||||||
jest.advanceTimersByTime(200);
|
jest.advanceTimersByTime(200);
|
||||||
});
|
});
|
||||||
|
|
||||||
await waitFor(() => expect(saveActiveChatState).toHaveBeenCalledTimes(1));
|
expect(result.current.messages).toEqual([
|
||||||
expect(saveActiveChatState.mock.calls[0][0]).toMatchObject({
|
|
||||||
sessionId: "chat-stream-1",
|
|
||||||
messages: [
|
|
||||||
expect.objectContaining({ role: "user", content: "第一条消息" }),
|
expect.objectContaining({ role: "user", content: "第一条消息" }),
|
||||||
expect.objectContaining({ role: "assistant", content: "收到" }),
|
expect.objectContaining({ role: "assistant", content: "收到" }),
|
||||||
],
|
]);
|
||||||
});
|
expect(result.current.activeSessionId).toBe("chat-stream-1");
|
||||||
} finally {
|
} finally {
|
||||||
jest.useRealTimers();
|
jest.useRealTimers();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,10 +4,10 @@ import { useCallback, useEffect, useRef, useState } from "react";
|
|||||||
|
|
||||||
import { abortAgentChat, forkAgentChat, rejectAgentQuestion, replyAgentPermission, replyAgentQuestion, resumeAgentChatStream, streamAgentChat } from "@/lib/chatStream";
|
import { abortAgentChat, forkAgentChat, rejectAgentQuestion, replyAgentPermission, replyAgentQuestion, resumeAgentChatStream, streamAgentChat } from "@/lib/chatStream";
|
||||||
import type { PermissionReply, StreamEvent } from "@/lib/chatStream";
|
import type { PermissionReply, StreamEvent } from "@/lib/chatStream";
|
||||||
import type { AgentArtifact, ChatSessionSummary, LoadedChatState, Message } from "../GlobalChatbox.types";
|
import type { AgentArtifact, ChatSessionSummary, Message } from "../GlobalChatbox.types";
|
||||||
import { cloneMessages } from "../GlobalChatbox.utils";
|
import { cloneMessages } from "../GlobalChatbox.utils";
|
||||||
import { createEmptyChatState, deleteChatSession, listChatSessions, loadChatSessionById, saveActiveChatState, updateChatSessionTitle } from "../chatStorage";
|
import { createEmptyChatState, deleteChatSession, listChatSessions, loadChatSessionById, updateChatSessionTitle } from "../chatStorage";
|
||||||
import { applyQuestionResponse, cancelRunningTodos, completeRunningProgress, createAssistantMessage, createPersistedStateKey, createTodoUpdateFromEvent, createUserMessage, dedupeQuestionsAcrossMessages, finalizeAssistantMessageAfterAbort, normalizeSessionTodos, toPermissionStatus, upsertPermission, upsertProgress, upsertQuestionAcrossMessages } from "./agentChatSessionState";
|
import { applyQuestionResponse, cancelRunningTodos, completeRunningProgress, createAssistantMessage, createTodoUpdateFromEvent, createUserMessage, dedupeQuestionsAcrossMessages, finalizeAssistantMessageAfterAbort, normalizeSessionTodos, toPermissionStatus, upsertPermission, upsertProgress, upsertQuestionAcrossMessages } from "./agentChatSessionState";
|
||||||
import type { PromptRunOptions, UseAgentChatSessionOptions } from "./useAgentChatSession.types";
|
import type { PromptRunOptions, UseAgentChatSessionOptions } from "./useAgentChatSession.types";
|
||||||
|
|
||||||
export const useAgentChatSession = ({
|
export const useAgentChatSession = ({
|
||||||
@@ -17,7 +17,6 @@ export const useAgentChatSession = ({
|
|||||||
getModel,
|
getModel,
|
||||||
getApprovalMode,
|
getApprovalMode,
|
||||||
}: UseAgentChatSessionOptions) => {
|
}: UseAgentChatSessionOptions) => {
|
||||||
const hydrationCompletedRef = useRef(false);
|
|
||||||
const hydrationNonceRef = useRef(0);
|
const hydrationNonceRef = useRef(0);
|
||||||
|
|
||||||
const [messages, setMessages] = useState<Message[]>([]);
|
const [messages, setMessages] = useState<Message[]>([]);
|
||||||
@@ -35,14 +34,6 @@ export const useAgentChatSession = ({
|
|||||||
const isSessionTitleManuallyEditedRef = useRef(false);
|
const isSessionTitleManuallyEditedRef = useRef(false);
|
||||||
const cancelPromiseRef = useRef<Promise<void> | null>(null);
|
const cancelPromiseRef = useRef<Promise<void> | null>(null);
|
||||||
const titleUpdateNonceRef = useRef(0);
|
const titleUpdateNonceRef = useRef(0);
|
||||||
const lastPersistedStateKeyRef = useRef(
|
|
||||||
createPersistedStateKey({
|
|
||||||
sessionId: undefined,
|
|
||||||
title: undefined,
|
|
||||||
isTitleManuallyEdited: false,
|
|
||||||
messages: [],
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
sessionIdRef.current = sessionId;
|
sessionIdRef.current = sessionId;
|
||||||
@@ -62,17 +53,9 @@ export const useAgentChatSession = ({
|
|||||||
|
|
||||||
const hydrate = async () => {
|
const hydrate = async () => {
|
||||||
setIsHydrating(true);
|
setIsHydrating(true);
|
||||||
hydrationCompletedRef.current = false;
|
|
||||||
|
|
||||||
if (!projectId) {
|
if (!projectId) {
|
||||||
sessionIdRef.current = undefined;
|
sessionIdRef.current = undefined;
|
||||||
lastPersistedStateKeyRef.current = createPersistedStateKey({
|
|
||||||
title: undefined,
|
|
||||||
isTitleManuallyEdited: false,
|
|
||||||
messages: [],
|
|
||||||
sessionId: undefined,
|
|
||||||
});
|
|
||||||
hydrationCompletedRef.current = true;
|
|
||||||
hydrationNonceRef.current += 1;
|
hydrationNonceRef.current += 1;
|
||||||
titleUpdateNonceRef.current += 1;
|
titleUpdateNonceRef.current += 1;
|
||||||
setMessages([]);
|
setMessages([]);
|
||||||
@@ -93,8 +76,6 @@ export const useAgentChatSession = ({
|
|||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
|
|
||||||
sessionIdRef.current = loadedState.sessionId;
|
sessionIdRef.current = loadedState.sessionId;
|
||||||
lastPersistedStateKeyRef.current = createPersistedStateKey(loadedState);
|
|
||||||
hydrationCompletedRef.current = true;
|
|
||||||
hydrationNonceRef.current += 1;
|
hydrationNonceRef.current += 1;
|
||||||
titleUpdateNonceRef.current += 1;
|
titleUpdateNonceRef.current += 1;
|
||||||
|
|
||||||
@@ -127,51 +108,6 @@ export const useAgentChatSession = ({
|
|||||||
};
|
};
|
||||||
}, [projectId]);
|
}, [projectId]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!projectId || isHydrating || !hydrationCompletedRef.current) return;
|
|
||||||
|
|
||||||
const currentHydrationNonce = hydrationNonceRef.current;
|
|
||||||
const persistTimer = window.setTimeout(() => {
|
|
||||||
if (isStreaming) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const state: LoadedChatState = {
|
|
||||||
title: sessionTitle,
|
|
||||||
isTitleManuallyEdited: isSessionTitleManuallyEdited,
|
|
||||||
messages,
|
|
||||||
sessionId,
|
|
||||||
};
|
|
||||||
|
|
||||||
const currentStateKey = createPersistedStateKey(state);
|
|
||||||
if (currentStateKey === lastPersistedStateKeyRef.current) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
void saveActiveChatState(state)
|
|
||||||
.then((sessionId) => {
|
|
||||||
if (hydrationNonceRef.current !== currentHydrationNonce) return;
|
|
||||||
sessionIdRef.current = sessionId;
|
|
||||||
lastPersistedStateKeyRef.current = createPersistedStateKey({
|
|
||||||
...state,
|
|
||||||
sessionId,
|
|
||||||
});
|
|
||||||
return listChatSessions();
|
|
||||||
})
|
|
||||||
.then((sessions) => {
|
|
||||||
if (!sessions || hydrationNonceRef.current !== currentHydrationNonce) return;
|
|
||||||
setChatSessions(sessions);
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
console.error("[GlobalChatbox] Failed to persist chat state:", error);
|
|
||||||
});
|
|
||||||
}, 150);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
window.clearTimeout(persistTimer);
|
|
||||||
};
|
|
||||||
}, [isHydrating, isSessionTitleManuallyEdited, isStreaming, messages, projectId, sessionId, sessionTitle]);
|
|
||||||
|
|
||||||
const appendArtifact = useCallback((messageId: string, artifact: AgentArtifact) => {
|
const appendArtifact = useCallback((messageId: string, artifact: AgentArtifact) => {
|
||||||
setMessages((prev) =>
|
setMessages((prev) =>
|
||||||
prev.map((message) =>
|
prev.map((message) =>
|
||||||
@@ -226,12 +162,6 @@ export const useAgentChatSession = ({
|
|||||||
const targetSessionId = event.sessionId || currentSessionId;
|
const targetSessionId = event.sessionId || currentSessionId;
|
||||||
if (targetSessionId === currentSessionId) {
|
if (targetSessionId === currentSessionId) {
|
||||||
setSessionTitle(nextTitle);
|
setSessionTitle(nextTitle);
|
||||||
lastPersistedStateKeyRef.current = createPersistedStateKey({
|
|
||||||
sessionId: targetSessionId,
|
|
||||||
title: nextTitle,
|
|
||||||
isTitleManuallyEdited: false,
|
|
||||||
messages: messagesRef.current,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
if (targetSessionId) {
|
if (targetSessionId) {
|
||||||
const currentNonce = ++titleUpdateNonceRef.current;
|
const currentNonce = ++titleUpdateNonceRef.current;
|
||||||
@@ -743,12 +673,6 @@ export const useAgentChatSession = ({
|
|||||||
hydrationNonceRef.current += 1;
|
hydrationNonceRef.current += 1;
|
||||||
titleUpdateNonceRef.current += 1;
|
titleUpdateNonceRef.current += 1;
|
||||||
sessionIdRef.current = undefined;
|
sessionIdRef.current = undefined;
|
||||||
lastPersistedStateKeyRef.current = createPersistedStateKey({
|
|
||||||
title: "新对话",
|
|
||||||
isTitleManuallyEdited: false,
|
|
||||||
messages: [],
|
|
||||||
sessionId: undefined,
|
|
||||||
});
|
|
||||||
setMessages([]);
|
setMessages([]);
|
||||||
setSessionTitle("新对话");
|
setSessionTitle("新对话");
|
||||||
setIsSessionTitleManuallyEdited(false);
|
setIsSessionTitleManuallyEdited(false);
|
||||||
@@ -777,7 +701,6 @@ export const useAgentChatSession = ({
|
|||||||
hydrationNonceRef.current += 1;
|
hydrationNonceRef.current += 1;
|
||||||
titleUpdateNonceRef.current += 1;
|
titleUpdateNonceRef.current += 1;
|
||||||
sessionIdRef.current = nextState.sessionId;
|
sessionIdRef.current = nextState.sessionId;
|
||||||
lastPersistedStateKeyRef.current = createPersistedStateKey(nextState);
|
|
||||||
setMessages(nextState.messages);
|
setMessages(nextState.messages);
|
||||||
setSessionTitle(nextState.title);
|
setSessionTitle(nextState.title);
|
||||||
setIsSessionTitleManuallyEdited(nextState.isTitleManuallyEdited ?? false);
|
setIsSessionTitleManuallyEdited(nextState.isTitleManuallyEdited ?? false);
|
||||||
@@ -821,12 +744,6 @@ export const useAgentChatSession = ({
|
|||||||
hydrationNonceRef.current += 1;
|
hydrationNonceRef.current += 1;
|
||||||
titleUpdateNonceRef.current += 1;
|
titleUpdateNonceRef.current += 1;
|
||||||
sessionIdRef.current = undefined;
|
sessionIdRef.current = undefined;
|
||||||
lastPersistedStateKeyRef.current = createPersistedStateKey({
|
|
||||||
title: undefined,
|
|
||||||
isTitleManuallyEdited: false,
|
|
||||||
messages: [],
|
|
||||||
sessionId: undefined,
|
|
||||||
});
|
|
||||||
setMessages([]);
|
setMessages([]);
|
||||||
setSessionTitle(undefined);
|
setSessionTitle(undefined);
|
||||||
setIsSessionTitleManuallyEdited(false);
|
setIsSessionTitleManuallyEdited(false);
|
||||||
@@ -842,7 +759,6 @@ export const useAgentChatSession = ({
|
|||||||
hydrationNonceRef.current += 1;
|
hydrationNonceRef.current += 1;
|
||||||
titleUpdateNonceRef.current += 1;
|
titleUpdateNonceRef.current += 1;
|
||||||
sessionIdRef.current = nextState.sessionId;
|
sessionIdRef.current = nextState.sessionId;
|
||||||
lastPersistedStateKeyRef.current = createPersistedStateKey(nextState);
|
|
||||||
setMessages(nextState.messages);
|
setMessages(nextState.messages);
|
||||||
setSessionTitle(nextState.title);
|
setSessionTitle(nextState.title);
|
||||||
setIsSessionTitleManuallyEdited(nextState.isTitleManuallyEdited ?? false);
|
setIsSessionTitleManuallyEdited(nextState.isTitleManuallyEdited ?? false);
|
||||||
@@ -884,18 +800,12 @@ export const useAgentChatSession = ({
|
|||||||
if (sessionIdRef.current === targetSessionId) {
|
if (sessionIdRef.current === targetSessionId) {
|
||||||
setSessionTitle(normalizedTitle);
|
setSessionTitle(normalizedTitle);
|
||||||
setIsSessionTitleManuallyEdited(true);
|
setIsSessionTitleManuallyEdited(true);
|
||||||
lastPersistedStateKeyRef.current = createPersistedStateKey({
|
|
||||||
sessionId: targetSessionId,
|
|
||||||
title: normalizedTitle,
|
|
||||||
isTitleManuallyEdited: true,
|
|
||||||
messages,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("[GlobalChatbox] Failed to rename chat session:", error);
|
console.error("[GlobalChatbox] Failed to rename chat session:", error);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[isHydrating, messages],
|
[isHydrating],
|
||||||
);
|
);
|
||||||
|
|
||||||
const createBranch = useCallback(
|
const createBranch = useCallback(
|
||||||
@@ -920,12 +830,6 @@ export const useAgentChatSession = ({
|
|||||||
const forkTitle = sessionTitle ? `${sessionTitle} 副本` : "新对话副本";
|
const forkTitle = sessionTitle ? `${sessionTitle} 副本` : "新对话副本";
|
||||||
setSessionTitle(forkTitle);
|
setSessionTitle(forkTitle);
|
||||||
try {
|
try {
|
||||||
await saveActiveChatState({
|
|
||||||
title: forkTitle,
|
|
||||||
isTitleManuallyEdited: false,
|
|
||||||
messages: copiedMessages,
|
|
||||||
sessionId: forkedSessionId,
|
|
||||||
});
|
|
||||||
setChatSessions(await listChatSessions());
|
setChatSessions(await listChatSessions());
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("[GlobalChatbox] Failed to refresh chat sessions after fork:", error);
|
console.error("[GlobalChatbox] Failed to refresh chat sessions after fork:", error);
|
||||||
|
|||||||
Reference in New Issue
Block a user