diff --git a/src/components/chat/AgentHeader.test.tsx b/src/components/chat/AgentHeader.test.tsx index 53929c6..a3d0dd1 100644 --- a/src/components/chat/AgentHeader.test.tsx +++ b/src/components/chat/AgentHeader.test.tsx @@ -33,7 +33,7 @@ describe("AgentHeader", () => { fireEvent.change(screen.getByPlaceholderText("请输入对话标题"), { target: { value: "更新后的标题" }, }); - fireEvent.click(screen.getByLabelText("确认修改对话标题")); + fireEvent.click(screen.getByLabelText("确认")); expect(onRenameSessionTitle).toHaveBeenCalledWith("更新后的标题"); }); diff --git a/src/components/chat/AgentHeader.tsx b/src/components/chat/AgentHeader.tsx index 2573158..3682457 100644 --- a/src/components/chat/AgentHeader.tsx +++ b/src/components/chat/AgentHeader.tsx @@ -129,10 +129,9 @@ export const AgentHeader = ({ /> - + {isEditingTitle ? ( - - + setDraftTitle(event.target.value)} @@ -152,27 +151,35 @@ export const AgentHeader = ({ flex: 1, minWidth: 0, "& .MuiOutlinedInput-root": { - height: 34, - bgcolor: alpha("#fff", 0.7), + padding: "6px 8px", + bgcolor: "transparent", borderRadius: 1.5, transition: "all 0.2s ease-in-out", + "&.Mui-focused": { + bgcolor: alpha("#fff", 0.6), + boxShadow: `0 2px 10px ${alpha("#000", 0.05)}`, + }, "& fieldset": { - borderColor: alpha("#000", 0.08), + borderColor: "transparent", }, "&:hover fieldset": { - borderColor: alpha(theme.palette.primary.main, 0.4), + borderColor: alpha(theme.palette.primary.main, 0.2), }, "&.Mui-focused fieldset": { - borderColor: theme.palette.primary.main, - borderWidth: "1.5px", - boxShadow: `0 0 0 3px ${alpha(theme.palette.primary.main, 0.1)}`, + borderColor: alpha(theme.palette.primary.main, 0.5), + borderWidth: "1px", }, }, "& .MuiInputBase-input": { - padding: "4px 12px", - fontSize: "1.05rem", - fontWeight: 700, - color: theme.palette.text.primary, + padding: 0, + height: "auto", + fontSize: "1.25rem", + fontWeight: 800, + letterSpacing: -0.3, + lineHeight: "1.2", + background: `linear-gradient(90deg, #01579b, #00838f)`, + WebkitBackgroundClip: "text", + WebkitTextFillColor: "transparent", } }} /> @@ -206,22 +213,22 @@ export const AgentHeader = ({ - ) : ( - <> - - + {displayTitle} @@ -235,8 +242,8 @@ export const AgentHeader = ({ onClick={handleStartEditing} disabled={isHydrating || isStreaming} sx={{ - width: 24, - height: 24, + width: 30, + height: 30, color: "text.secondary", bgcolor: alpha("#fff", 0.45), "&:hover": { @@ -245,20 +252,12 @@ export const AgentHeader = ({ }, }} > - + ) : null} - - - {isStreaming - ? "正在思考分析任务..." - : displayTitle === "TJWater Agent" - ? "基于大模型的水力分析引擎" - : "当前会话标题"} - - + )} diff --git a/src/components/chat/AgentHistoryPanel.tsx b/src/components/chat/AgentHistoryPanel.tsx index 496b816..79230a2 100644 --- a/src/components/chat/AgentHistoryPanel.tsx +++ b/src/components/chat/AgentHistoryPanel.tsx @@ -85,7 +85,6 @@ export const AgentHistoryPanel = ({ const [keyword, setKeyword] = React.useState(""); const [editingSessionId, setEditingSessionId] = React.useState(null); const [draftTitle, setDraftTitle] = React.useState(""); - const [isDeleteDialogOpen, setIsDeleteDialogOpen] = React.useState(false); const [pendingDeleteSessionId, setPendingDeleteSessionId] = React.useState(null); const filteredSessions = React.useMemo(() => { @@ -300,7 +299,7 @@ export const AgentHistoryPanel = ({ }, }} > - + {editingSessionId === session.id ? ( @@ -386,6 +385,38 @@ export const AgentHistoryPanel = ({ + ) : pendingDeleteSessionId === session.id ? ( + + + + + + 确认删除此会话? + + ) : ( - - + {!(editingSessionId === session.id || pendingDeleteSessionId === session.id) && ( + + { event.stopPropagation(); setPendingDeleteSessionId(session.id); - setIsDeleteDialogOpen(true); }} + disabled={isHydrating} sx={{ - width: 24, - height: 24, + width: 28, + height: 28, color: "text.secondary", "&:hover": { color: "error.main", @@ -459,6 +491,48 @@ export const AgentHistoryPanel = ({ + )} + + {pendingDeleteSessionId === session.id && ( + + { + event.stopPropagation(); + onDeleteSession(session.id); + setPendingDeleteSessionId(null); + }} + sx={{ + width: 28, + height: 28, + color: "error.main", + bgcolor: alpha("#ef5350", 0.1), + "&:hover": { bgcolor: alpha("#ef5350", 0.2) }, + }} + > + + + { + event.stopPropagation(); + setPendingDeleteSessionId(null); + }} + sx={{ + width: 28, + height: 28, + color: "text.secondary", + bgcolor: alpha("#000", 0.05), + "&:hover": { bgcolor: alpha("#000", 0.1) }, + }} + > + + + + )} + ); @@ -470,97 +544,6 @@ export const AgentHistoryPanel = ({ )} - - setIsDeleteDialogOpen(false)} - sx={{ zIndex: (theme) => theme.zIndex.modal + 200 }} - TransitionProps={{ - onExited: () => setPendingDeleteSessionId(null) - }} - PaperProps={{ - sx: { - borderRadius: 4, - bgcolor: alpha("#fff", 0.85), - backdropFilter: "blur(24px)", - boxShadow: `0 16px 40px ${alpha("#000", 0.12)}`, - border: `1px solid ${alpha("#fff", 0.6)}`, - minWidth: 320, - }, - }} - > - - - - - - 删除确认 - - - - - 确定要删除 - {pendingDeleteSession ? ( - - “{pendingDeleteSession.title}” - - ) : ( - "该会话" - )} - 吗? -
- 此操作不可撤销,删除后聊天记录将永久丢失。 -
-
- - - - -
); }; diff --git a/src/components/chat/chatStorage.test.ts b/src/components/chat/chatStorage.test.ts new file mode 100644 index 0000000..18f7119 --- /dev/null +++ b/src/components/chat/chatStorage.test.ts @@ -0,0 +1,142 @@ +import type { ChatSessionRecord } from "./GlobalChatbox.types"; +import { + createEmptyChatSession, + loadChatSessionById, + saveActiveChatState, + updateChatSessionTitle, +} from "./chatStorage"; + +type StoreName = "sessions" | "meta"; + +const stores: Record> = { + sessions: new Map(), + meta: new Map(), +}; + +const mockDb = { + get: jest.fn(async (storeName: StoreName, key: string) => stores[storeName].get(key)), + getAll: jest.fn(async (storeName: StoreName) => Array.from(stores[storeName].values())), + put: jest.fn(async (storeName: StoreName, value: { id?: string; key?: string }) => { + const key = storeName === "sessions" ? value.id : value.key; + if (!key) { + throw new Error(`Missing key for store ${storeName}`); + } + stores[storeName].set(key, value); + return key; + }), + delete: jest.fn(async (storeName: StoreName, key: string) => { + stores[storeName].delete(key); + }), +}; + +jest.mock("idb", () => ({ + openDB: jest.fn(async () => mockDb), +})); + +describe("chatStorage timestamp semantics", () => { + let now = new Date("2026-05-19T09:00:00+08:00").getTime(); + let dateNowSpy: jest.SpyInstance; + + beforeEach(() => { + stores.sessions.clear(); + stores.meta.clear(); + mockDb.get.mockClear(); + mockDb.getAll.mockClear(); + mockDb.put.mockClear(); + mockDb.delete.mockClear(); + window.localStorage.clear(); + now = new Date("2026-05-19T09:00:00+08:00").getTime(); + dateNowSpy = jest.spyOn(Date, "now").mockImplementation(() => now); + }); + + afterEach(() => { + dateNowSpy.mockRestore(); + }); + + it("keeps anchor and content timestamps when reopening an old session", async () => { + const record: ChatSessionRecord = { + id: "old-session", + title: "很久之前的会话", + isTitleManuallyEdited: false, + createdAt: new Date("2026-04-01T10:00:00+08:00").getTime(), + updatedAt: new Date("2026-04-01T10:30:00+08:00").getTime(), + sessionId: "remote-1", + messages: [ + { + id: "message-1", + role: "user", + content: "老问题", + branchRootId: "message-1", + }, + ], + branchGroups: [], + }; + stores.sessions.set(record.id, record); + + const loadedState = await loadChatSessionById(record.id); + now = new Date("2026-05-19T09:30:00+08:00").getTime(); + await saveActiveChatState(loadedState); + + expect(stores.sessions.get(record.id)).toMatchObject({ + createdAt: record.createdAt, + updatedAt: record.updatedAt, + }); + }); + + it("does not change timestamps when renaming a session", async () => { + const record: ChatSessionRecord = { + id: "rename-session", + title: "旧标题", + isTitleManuallyEdited: false, + createdAt: new Date("2026-04-10T08:00:00+08:00").getTime(), + updatedAt: new Date("2026-04-10T08:05:00+08:00").getTime(), + sessionId: "remote-2", + messages: [ + { + id: "message-2", + role: "user", + content: "保留时间", + branchRootId: "message-2", + }, + ], + branchGroups: [], + }; + stores.sessions.set(record.id, record); + + now = new Date("2026-05-19T11:00:00+08:00").getTime(); + await updateChatSessionTitle(record.id, "新标题", { + isTitleManuallyEdited: true, + }); + + expect(stores.sessions.get(record.id)).toMatchObject({ + title: "新标题", + isTitleManuallyEdited: true, + createdAt: record.createdAt, + updatedAt: record.updatedAt, + }); + }); + + it("anchors createdAt to the first real message time for a new empty session", async () => { + const emptyState = await createEmptyChatSession(); + const storageSessionId = emptyState.storageSessionId; + + now = new Date("2026-05-19T09:05:00+08:00").getTime(); + await saveActiveChatState({ + ...emptyState, + messages: [ + { + id: "message-3", + role: "user", + content: "第一条消息", + branchRootId: "message-3", + }, + ], + sessionId: "remote-3", + }); + + expect(stores.sessions.get(storageSessionId!)).toMatchObject({ + createdAt: now, + updatedAt: now, + }); + }); +}); diff --git a/src/components/chat/chatStorage.ts b/src/components/chat/chatStorage.ts index 51d8a34..280b996 100644 --- a/src/components/chat/chatStorage.ts +++ b/src/components/chat/chatStorage.ts @@ -51,6 +51,17 @@ const sanitizeMessages = (messages: Message[] | undefined) => const sanitizeBranchGroups = (branchGroups: BranchGroup[] | undefined) => Array.isArray(branchGroups) ? cloneBranchGroups(branchGroups) : []; +const serializeConversationState = (state: { + messages: Message[]; + branchGroups: BranchGroup[]; + sessionId?: string; +}) => + JSON.stringify({ + messages: sanitizeMessages(state.messages), + branchGroups: sanitizeBranchGroups(state.branchGroups), + sessionId: state.sessionId ?? null, + }); + const hasChatContent = (state: { messages: Message[]; branchGroups: BranchGroup[]; @@ -260,6 +271,9 @@ export const saveActiveChatState = async ( const storageSessionId = state.storageSessionId ?? createId(); const preferredTitle = state.title?.trim(); const finalTitle = preferredTitle || existingSession?.title || "新对话"; + const hasContentChanged = + !existingSession || + serializeConversationState(existingSession) !== serializeConversationState(state); const shouldAnchorCreatedAtToFirstMessage = Boolean(existingSession) && !hasChatContent(existingSession) && hasContent; const nextRecord: ChatSessionRecord = { @@ -269,7 +283,7 @@ export const saveActiveChatState = async ( createdAt: shouldAnchorCreatedAtToFirstMessage ? now : existingSession?.createdAt ?? now, - updatedAt: now, + updatedAt: hasContentChanged ? now : existingSession?.updatedAt ?? now, sessionId: state.sessionId, messages: sanitizeMessages(state.messages), branchGroups: sanitizeBranchGroups(state.branchGroups), @@ -315,7 +329,6 @@ export const updateChatSessionTitle = async ( title: normalizedTitle, isTitleManuallyEdited: options?.isTitleManuallyEdited ?? session.isTitleManuallyEdited ?? false, - updatedAt: Date.now(), }); }; diff --git a/src/components/chat/hooks/useAgentChatSession.ts b/src/components/chat/hooks/useAgentChatSession.ts index b517898..2ac31db 100644 --- a/src/components/chat/hooks/useAgentChatSession.ts +++ b/src/components/chat/hooks/useAgentChatSession.ts @@ -48,6 +48,16 @@ type PromptRunOptions = { assistantMessage?: Message; }; +const createPersistedStateKey = (state: LoadedChatState) => + JSON.stringify({ + storageSessionId: state.storageSessionId ?? null, + title: state.title ?? null, + isTitleManuallyEdited: state.isTitleManuallyEdited ?? false, + sessionId: state.sessionId ?? null, + messages: state.messages, + branchGroups: state.branchGroups, + }); + const upsertProgress = ( progress: ChatProgress[] | undefined, event: StreamEvent & { type: "progress" }, @@ -158,6 +168,16 @@ export const useAgentChatSession = ({ const isSessionTitleManuallyEditedRef = useRef(false); const cancelPromiseRef = useRef | null>(null); const titleUpdateNonceRef = useRef(0); + const lastPersistedStateKeyRef = useRef( + createPersistedStateKey({ + storageSessionId: undefined, + title: undefined, + isTitleManuallyEdited: false, + messages: [], + sessionId: undefined, + branchGroups: [], + }), + ); useEffect(() => { sessionIdRef.current = sessionId; @@ -180,6 +200,7 @@ export const useAgentChatSession = ({ storageSessionIdRef.current = loadedState.storageSessionId; sessionIdRef.current = loadedState.sessionId; + lastPersistedStateKeyRef.current = createPersistedStateKey(loadedState); hydrationCompletedRef.current = true; hydrationNonceRef.current += 1; titleUpdateNonceRef.current += 1; @@ -219,11 +240,19 @@ export const useAgentChatSession = ({ sessionId, branchGroups, }; + const currentStateKey = createPersistedStateKey(state); + if (currentStateKey === lastPersistedStateKeyRef.current) { + return; + } void saveActiveChatState(state) .then((storageSessionId) => { if (hydrationNonceRef.current !== currentHydrationNonce) return; storageSessionIdRef.current = storageSessionId; + lastPersistedStateKeyRef.current = createPersistedStateKey({ + ...state, + storageSessionId, + }); return listChatSessions(); }) .then((sessions) => { @@ -503,6 +532,14 @@ export const useAgentChatSession = ({ 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); }, []); @@ -521,6 +558,7 @@ export const useAgentChatSession = ({ titleUpdateNonceRef.current += 1; storageSessionIdRef.current = newState.storageSessionId; sessionIdRef.current = newState.sessionId; + lastPersistedStateKeyRef.current = createPersistedStateKey(newState); setMessages(newState.messages); setSessionTitle(newState.title); setIsSessionTitleManuallyEdited(newState.isTitleManuallyEdited ?? false); @@ -547,6 +585,7 @@ export const useAgentChatSession = ({ titleUpdateNonceRef.current += 1; storageSessionIdRef.current = nextState.storageSessionId; sessionIdRef.current = nextState.sessionId; + lastPersistedStateKeyRef.current = createPersistedStateKey(nextState); setBranchTransition(null); setMessages(nextState.messages); setSessionTitle(nextState.title); @@ -581,6 +620,14 @@ export const useAgentChatSession = ({ titleUpdateNonceRef.current += 1; storageSessionIdRef.current = undefined; sessionIdRef.current = undefined; + lastPersistedStateKeyRef.current = createPersistedStateKey({ + storageSessionId: undefined, + title: undefined, + isTitleManuallyEdited: false, + messages: [], + sessionId: undefined, + branchGroups: [], + }); setBranchTransition(null); setMessages([]); setSessionTitle(undefined); @@ -599,6 +646,7 @@ export const useAgentChatSession = ({ titleUpdateNonceRef.current += 1; storageSessionIdRef.current = nextState.storageSessionId; sessionIdRef.current = nextState.sessionId; + lastPersistedStateKeyRef.current = createPersistedStateKey(nextState); setBranchTransition(null); setMessages(nextState.messages); setSessionTitle(nextState.title); @@ -637,12 +685,20 @@ export const useAgentChatSession = ({ if (storageSessionIdRef.current === targetStorageSessionId) { setSessionTitle(normalizedTitle); setIsSessionTitleManuallyEdited(true); + lastPersistedStateKeyRef.current = createPersistedStateKey({ + storageSessionId: targetStorageSessionId, + title: normalizedTitle, + isTitleManuallyEdited: true, + messages, + sessionId: sessionIdRef.current, + branchGroups, + }); } } catch (error) { console.error("[GlobalChatbox] Failed to rename chat session:", error); } }, - [isHydrating], + [branchGroups, isHydrating, messages], ); const regenerate = useCallback(async () => {