From 9106b8d4a90464ddb16f76bcbf22cc7ed3bcae58 Mon Sep 17 00:00:00 2001 From: Huarch Date: Tue, 19 May 2026 16:42:28 +0800 Subject: [PATCH] =?UTF-8?q?=E5=A2=9E=E5=8A=A0=E4=BC=9A=E8=AF=9D=E6=A0=87?= =?UTF-8?q?=E9=A2=98=E9=87=8D=E5=91=BD=E5=90=8D=E5=8A=9F=E8=83=BD=EF=BC=8C?= =?UTF-8?q?=E4=BC=98=E5=8C=96=E5=8E=86=E5=8F=B2=E9=9D=A2=E6=9D=BF=E4=BA=A4?= =?UTF-8?q?=E4=BA=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/chat/AgentHeader.test.tsx | 40 +++ src/components/chat/AgentHeader.tsx | 194 +++++++++++++-- .../chat/AgentHistoryPanel.test.tsx | 40 +++ src/components/chat/AgentHistoryPanel.tsx | 228 ++++++++++++++---- src/components/chat/GlobalChatbox.tsx | 20 ++ src/components/chat/GlobalChatbox.types.ts | 2 + src/components/chat/chatStorage.ts | 10 + .../chat/hooks/useAgentChatSession.ts | 45 +++- 8 files changed, 508 insertions(+), 71 deletions(-) create mode 100644 src/components/chat/AgentHeader.test.tsx create mode 100644 src/components/chat/AgentHistoryPanel.test.tsx diff --git a/src/components/chat/AgentHeader.test.tsx b/src/components/chat/AgentHeader.test.tsx new file mode 100644 index 0000000..53929c6 --- /dev/null +++ b/src/components/chat/AgentHeader.test.tsx @@ -0,0 +1,40 @@ +import React from "react"; +import { fireEvent, render, screen } from "@testing-library/react"; +import { ThemeProvider, createTheme } from "@mui/material/styles"; + +import { AgentHeader } from "./AgentHeader"; + +jest.mock("next/image", () => ({ + __esModule: true, + default: (props: React.ComponentProps<"img">) => {props.alt, +})); + +const renderWithTheme = (ui: React.ReactElement) => + render({ui}); + +describe("AgentHeader", () => { + it("submits a renamed active session title", () => { + const onRenameSessionTitle = jest.fn(); + + renderWithTheme( + , + ); + + fireEvent.click(screen.getByRole("button", { name: "修改对话标题" })); + fireEvent.change(screen.getByPlaceholderText("请输入对话标题"), { + target: { value: "更新后的标题" }, + }); + fireEvent.click(screen.getByLabelText("确认修改对话标题")); + + expect(onRenameSessionTitle).toHaveBeenCalledWith("更新后的标题"); + }); +}); diff --git a/src/components/chat/AgentHeader.tsx b/src/components/chat/AgentHeader.tsx index be57972..2573158 100644 --- a/src/components/chat/AgentHeader.tsx +++ b/src/components/chat/AgentHeader.tsx @@ -8,34 +8,69 @@ import { Box, IconButton, Stack, + TextField, Tooltip, Typography, alpha, useTheme, } from "@mui/material"; -import EditNoteRounded from "@mui/icons-material/EditNoteRounded"; +import CheckRounded from "@mui/icons-material/CheckRounded"; import CloseRounded from "@mui/icons-material/CloseRounded"; +import EditRounded from "@mui/icons-material/EditRounded"; +import EditNoteRounded from "@mui/icons-material/EditNoteRounded"; import HistoryRounded from "@mui/icons-material/HistoryRounded"; type AgentHeaderProps = { sessionTitle?: string; + canRenameSessionTitle?: boolean; + isHydrating?: boolean; isStreaming: boolean; isHistoryOpen: boolean; onHistoryToggle: () => void; + onRenameSessionTitle?: (title: string) => void; onNewConversation: () => void; onClose: () => void; }; export const AgentHeader = ({ sessionTitle, + canRenameSessionTitle = false, + isHydrating = false, isStreaming, isHistoryOpen, onHistoryToggle, + onRenameSessionTitle, onNewConversation, onClose, }: AgentHeaderProps) => { const theme = useTheme(); const displayTitle = sessionTitle?.trim() || "TJWater Agent"; + const [isEditingTitle, setIsEditingTitle] = React.useState(false); + const [draftTitle, setDraftTitle] = React.useState(sessionTitle?.trim() || ""); + + React.useEffect(() => { + if (!isEditingTitle) { + setDraftTitle(sessionTitle?.trim() || ""); + } + }, [isEditingTitle, sessionTitle]); + + const handleStartEditing = () => { + if (!canRenameSessionTitle || isHydrating || isStreaming) return; + setDraftTitle(sessionTitle?.trim() || ""); + setIsEditingTitle(true); + }; + + const handleCancelEditing = () => { + setDraftTitle(sessionTitle?.trim() || ""); + setIsEditingTitle(false); + }; + + const handleConfirmEditing = () => { + const normalizedTitle = draftTitle.trim(); + if (!normalizedTitle) return; + onRenameSessionTitle?.(normalizedTitle); + setIsEditingTitle(false); + }; return ( - - - {displayTitle} - - - {isStreaming - ? "正在思考分析任务..." - : displayTitle === "TJWater Agent" - ? "基于大模型的水力分析引擎" - : "当前会话标题"} - + + {isEditingTitle ? ( + + + setDraftTitle(event.target.value)} + size="small" + autoFocus + placeholder="请输入对话标题" + onKeyDown={(event) => { + if (event.key === "Enter") { + event.preventDefault(); + handleConfirmEditing(); + } else if (event.key === "Escape") { + event.preventDefault(); + handleCancelEditing(); + } + }} + sx={{ + flex: 1, + minWidth: 0, + "& .MuiOutlinedInput-root": { + height: 34, + bgcolor: alpha("#fff", 0.7), + borderRadius: 1.5, + transition: "all 0.2s ease-in-out", + "& fieldset": { + borderColor: alpha("#000", 0.08), + }, + "&:hover fieldset": { + borderColor: alpha(theme.palette.primary.main, 0.4), + }, + "&.Mui-focused fieldset": { + borderColor: theme.palette.primary.main, + borderWidth: "1.5px", + boxShadow: `0 0 0 3px ${alpha(theme.palette.primary.main, 0.1)}`, + }, + }, + "& .MuiInputBase-input": { + padding: "4px 12px", + fontSize: "1.05rem", + fontWeight: 700, + color: theme.palette.text.primary, + } + }} + /> + + + + + + + + + ) : ( + <> + + + {displayTitle} + + {canRenameSessionTitle ? ( + + + + + + + + ) : null} + + + {isStreaming + ? "正在思考分析任务..." + : displayTitle === "TJWater Agent" + ? "基于大模型的水力分析引擎" + : "当前会话标题"} + + + )} diff --git a/src/components/chat/AgentHistoryPanel.test.tsx b/src/components/chat/AgentHistoryPanel.test.tsx new file mode 100644 index 0000000..8dc3d6d --- /dev/null +++ b/src/components/chat/AgentHistoryPanel.test.tsx @@ -0,0 +1,40 @@ +import React from "react"; +import { fireEvent, render, screen } from "@testing-library/react"; +import { ThemeProvider, createTheme } from "@mui/material/styles"; + +import { AgentHistoryPanel } from "./AgentHistoryPanel"; + +const renderWithTheme = (ui: React.ReactElement) => + render({ui}); + +describe("AgentHistoryPanel", () => { + it("renames a history session from the list", () => { + const onRenameSession = jest.fn(); + + renderWithTheme( + , + ); + + fireEvent.click(screen.getByRole("button", { name: "修改会话标题" })); + fireEvent.change(screen.getByPlaceholderText("请输入会话标题"), { + target: { value: "新的会话标题" }, + }); + fireEvent.click(screen.getByLabelText("确认修改历史会话标题")); + + expect(onRenameSession).toHaveBeenCalledWith("session-1", "新的会话标题"); + }); +}); diff --git a/src/components/chat/AgentHistoryPanel.tsx b/src/components/chat/AgentHistoryPanel.tsx index 21b8dde..ca606c4 100644 --- a/src/components/chat/AgentHistoryPanel.tsx +++ b/src/components/chat/AgentHistoryPanel.tsx @@ -18,7 +18,11 @@ import { Tooltip, Typography, alpha, + useTheme, } from "@mui/material"; +import CheckRounded from "@mui/icons-material/CheckRounded"; +import CloseRounded from "@mui/icons-material/CloseRounded"; +import EditRounded from "@mui/icons-material/EditRounded"; import EditNoteRounded from "@mui/icons-material/EditNoteRounded"; import DeleteOutlineRounded from "@mui/icons-material/DeleteOutlineRounded"; import ChatBubbleOutlineRounded from "@mui/icons-material/ChatBubbleOutlineRounded"; @@ -31,6 +35,7 @@ type AgentHistoryPanelProps = { activeSessionId?: string; isHydrating?: boolean; onNewSession: () => void; + onRenameSession: (sessionId: string, title: string) => void; onSelectSession: (sessionId: string) => void; onDeleteSession: (sessionId: string) => void; }; @@ -68,14 +73,19 @@ const getSessionGroupLabel = (timestamp: number) => { }; export const AgentHistoryPanel = ({ + sessions, activeSessionId, isHydrating = false, onNewSession, + onRenameSession, onSelectSession, onDeleteSession, }: AgentHistoryPanelProps) => { + const theme = useTheme(); 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); @@ -105,6 +115,23 @@ export const AgentHistoryPanel = ({ (session) => session.id === pendingDeleteSessionId, ); + const handleStartRename = (sessionId: string, title: string) => { + setEditingSessionId(sessionId); + setDraftTitle(title); + }; + + const handleCancelRename = () => { + setEditingSessionId(null); + setDraftTitle(""); + }; + + const handleConfirmRename = (sessionId: string) => { + const normalizedTitle = draftTitle.trim(); + if (!normalizedTitle) return; + onRenameSession(sessionId, normalizedTitle); + handleCancelRename(); + }; + return ( <> onSelectSession(session.id)} + onClick={() => { + if (editingSessionId === session.id) return; + onSelectSession(session.id); + }} sx={{ px: 1.25, py: 1, @@ -259,49 +289,163 @@ export const AgentHistoryPanel = ({ > - - {session.title} - - - {formatRelativeDate(session.updatedAt)} - + {editingSessionId === session.id ? ( + + setDraftTitle(event.target.value)} + size="small" + autoFocus + placeholder="请输入会话标题" + onClick={(event) => event.stopPropagation()} + onKeyDown={(event) => { + if (event.key === "Enter") { + event.preventDefault(); + event.stopPropagation(); + handleConfirmRename(session.id); + } else if (event.key === "Escape") { + event.preventDefault(); + event.stopPropagation(); + handleCancelRename(); + } + }} + sx={{ + flex: 1, + minWidth: 0, + "& .MuiOutlinedInput-root": { + height: 32, + bgcolor: alpha("#fff", 0.75), + borderRadius: 1.5, + transition: "all 0.2s ease-in-out", + "& fieldset": { + borderColor: alpha("#000", 0.08), + }, + "&:hover fieldset": { + borderColor: alpha(theme.palette.primary.main, 0.4), + }, + "&.Mui-focused fieldset": { + borderColor: theme.palette.primary.main, + borderWidth: "1.5px", + boxShadow: `0 0 0 3px ${alpha(theme.palette.primary.main, 0.1)}`, + }, + }, + "& .MuiInputBase-input": { + padding: "4px 10px", + fontSize: "0.85rem", + fontWeight: 700, + color: theme.palette.text.primary, + } + }} + /> + { + event.stopPropagation(); + handleConfirmRename(session.id); + }} + disabled={!draftTitle.trim()} + sx={{ + width: 28, + height: 28, + color: "success.main", + bgcolor: alpha(theme.palette.success.main, 0.1), + "&:hover": { bgcolor: alpha(theme.palette.success.main, 0.2) }, + }} + > + + + { + event.stopPropagation(); + handleCancelRename(); + }} + sx={{ + width: 28, + height: 28, + color: "text.secondary", + bgcolor: alpha("#000", 0.05), + "&:hover": { bgcolor: alpha("#000", 0.1) }, + }} + > + + + + ) : ( + + + {session.title} + + + {formatRelativeDate(session.updatedAt)} + + + )} - - - { - event.stopPropagation(); - setPendingDeleteSessionId(session.id); - setIsDeleteDialogOpen(true); - }} - sx={{ - width: 24, - height: 24, - color: "text.secondary", - "&:hover": { - color: "error.main", - bgcolor: alpha("#ef5350", 0.08), - }, - }} - > - - - - + + + + { + event.stopPropagation(); + handleStartRename(session.id, session.title); + }} + disabled={isHydrating || editingSessionId === session.id} + sx={{ + width: 24, + height: 24, + color: "text.secondary", + "&:hover": { + color: "primary.main", + bgcolor: alpha("#00acc1", 0.08), + }, + }} + > + + + + + + + { + event.stopPropagation(); + setPendingDeleteSessionId(session.id); + setIsDeleteDialogOpen(true); + }} + sx={{ + width: 24, + height: 24, + color: "text.secondary", + "&:hover": { + color: "error.main", + bgcolor: alpha("#ef5350", 0.08), + }, + }} + > + + + + + ); diff --git a/src/components/chat/GlobalChatbox.tsx b/src/components/chat/GlobalChatbox.tsx index 921ab5b..8f4db76 100644 --- a/src/components/chat/GlobalChatbox.tsx +++ b/src/components/chat/GlobalChatbox.tsx @@ -70,6 +70,7 @@ export const GlobalChatbox: React.FC = ({ open, onClose }) => { cycleBranch, abort, createSession, + renameSession, removeSession, switchSession, } = useAgentChatSession({ @@ -134,6 +135,21 @@ export const GlobalChatbox: React.FC = ({ open, onClose }) => { [removeSession], ); + const handleRenameSession = useCallback( + (storageSessionId: string, title: string) => { + void renameSession(storageSessionId, title); + }, + [renameSession], + ); + + const handleRenameActiveSession = useCallback( + (title: string) => { + if (!activeStorageSessionId) return; + void renameSession(activeStorageSessionId, title); + }, + [activeStorageSessionId, renameSession], + ); + const handleMouseDown = useCallback((event: React.MouseEvent) => { event.preventDefault(); setIsResizing(true); @@ -231,9 +247,12 @@ export const GlobalChatbox: React.FC = ({ open, onClose }) => { @@ -277,6 +296,7 @@ export const GlobalChatbox: React.FC = ({ open, onClose }) => { handleSelectSession(id); setIsHistoryOpen(false); }} + onRenameSession={handleRenameSession} onDeleteSession={handleDeleteSession} /> diff --git a/src/components/chat/GlobalChatbox.types.ts b/src/components/chat/GlobalChatbox.types.ts index 4fae4b3..acba4e7 100644 --- a/src/components/chat/GlobalChatbox.types.ts +++ b/src/components/chat/GlobalChatbox.types.ts @@ -75,6 +75,7 @@ export type LegacyPersistedChatState = { export type ChatSessionRecord = { id: string; title: string; + isTitleManuallyEdited?: boolean; createdAt: number; updatedAt: number; sessionId?: string; @@ -98,6 +99,7 @@ export type ChatStorageMeta = { export type LoadedChatState = { storageSessionId?: string; title?: string; + isTitleManuallyEdited?: boolean; messages: Message[]; sessionId?: string; branchGroups: BranchGroup[]; diff --git a/src/components/chat/chatStorage.ts b/src/components/chat/chatStorage.ts index 3574362..3c2d794 100644 --- a/src/components/chat/chatStorage.ts +++ b/src/components/chat/chatStorage.ts @@ -39,6 +39,7 @@ type ChatDB = DBSchema & { const emptyLoadedChatState = (): LoadedChatState => ({ storageSessionId: undefined, title: undefined, + isTitleManuallyEdited: false, messages: [], sessionId: undefined, branchGroups: [], @@ -55,6 +56,7 @@ const toLoadedChatState = (session: ChatSessionRecord | undefined): LoadedChatSt return { storageSessionId: session.id, title: session.title, + isTitleManuallyEdited: session.isTitleManuallyEdited ?? false, messages: sanitizeMessages(session.messages), sessionId: session.sessionId, branchGroups: sanitizeBranchGroups(session.branchGroups), @@ -163,6 +165,7 @@ const migrateLegacyLocalStorage = async () => { const sessionRecord: ChatSessionRecord = { id: createId(), title: "新对话", + isTitleManuallyEdited: false, createdAt: now, updatedAt: now, sessionId: legacyState.sessionId, @@ -241,6 +244,7 @@ export const saveActiveChatState = async ( const nextRecord: ChatSessionRecord = { id: storageSessionId, title: finalTitle, + isTitleManuallyEdited: state.isTitleManuallyEdited ?? existingSession?.isTitleManuallyEdited ?? false, createdAt: existingSession?.createdAt ?? now, updatedAt: now, sessionId: state.sessionId, @@ -272,6 +276,9 @@ export const listChatSessions = async (): Promise => { export const updateChatSessionTitle = async ( storageSessionId: string, title: string, + options?: { + isTitleManuallyEdited?: boolean; + }, ): Promise => { if (typeof window === "undefined") return; @@ -285,6 +292,8 @@ export const updateChatSessionTitle = async ( await db.put(SESSION_STORE, { ...session, title: normalizedTitle, + isTitleManuallyEdited: + options?.isTitleManuallyEdited ?? session.isTitleManuallyEdited ?? false, updatedAt: Date.now(), }); }; @@ -298,6 +307,7 @@ export const createEmptyChatSession = async (): Promise => { const session: ChatSessionRecord = { id: createId(), title: "新对话", + isTitleManuallyEdited: false, createdAt: now, updatedAt: now, sessionId: undefined, diff --git a/src/components/chat/hooks/useAgentChatSession.ts b/src/components/chat/hooks/useAgentChatSession.ts index 08e6f97..b517898 100644 --- a/src/components/chat/hooks/useAgentChatSession.ts +++ b/src/components/chat/hooks/useAgentChatSession.ts @@ -146,6 +146,7 @@ export const useAgentChatSession = ({ const [messages, setMessages] = useState([]); const [sessionTitle, setSessionTitle] = useState(undefined); + const [isSessionTitleManuallyEdited, setIsSessionTitleManuallyEdited] = useState(false); const [sessionId, setSessionId] = useState(undefined); const [branchGroups, setBranchGroups] = useState([]); const [chatSessions, setChatSessions] = useState([]); @@ -154,6 +155,7 @@ export const useAgentChatSession = ({ const [isHydrating, setIsHydrating] = useState(true); const abortRef = useRef(null); const sessionIdRef = useRef(undefined); + const isSessionTitleManuallyEditedRef = useRef(false); const cancelPromiseRef = useRef | null>(null); const titleUpdateNonceRef = useRef(0); @@ -161,6 +163,10 @@ export const useAgentChatSession = ({ sessionIdRef.current = sessionId; }, [sessionId]); + useEffect(() => { + isSessionTitleManuallyEditedRef.current = isSessionTitleManuallyEdited; + }, [isSessionTitleManuallyEdited]); + useEffect(() => { let cancelled = false; @@ -180,6 +186,7 @@ export const useAgentChatSession = ({ setMessages(loadedState.messages); setSessionTitle(loadedState.title); + setIsSessionTitleManuallyEdited(loadedState.isTitleManuallyEdited ?? false); setSessionId(loadedState.sessionId); setBranchGroups(loadedState.branchGroups); setChatSessions(sessions); @@ -207,6 +214,7 @@ export const useAgentChatSession = ({ const state: LoadedChatState = { storageSessionId: storageSessionIdRef.current, title: sessionTitle, + isTitleManuallyEdited: isSessionTitleManuallyEdited, messages, sessionId, branchGroups, @@ -230,7 +238,7 @@ export const useAgentChatSession = ({ return () => { window.clearTimeout(persistTimer); }; - }, [branchGroups, isHydrating, messages, sessionId, sessionTitle]); + }, [branchGroups, isHydrating, isSessionTitleManuallyEdited, messages, sessionId, sessionTitle]); useEffect(() => { setBranchGroups((prev) => { @@ -354,12 +362,14 @@ export const useAgentChatSession = ({ }); } else if (event.type === "session_title") { const nextTitle = event.title.trim(); - if (nextTitle) { + if (nextTitle && !isSessionTitleManuallyEditedRef.current) { setSessionTitle(nextTitle); const currentStorageSessionId = storageSessionIdRef.current; if (currentStorageSessionId) { const currentNonce = ++titleUpdateNonceRef.current; - void updateChatSessionTitle(currentStorageSessionId, nextTitle) + void updateChatSessionTitle(currentStorageSessionId, nextTitle, { + isTitleManuallyEdited: false, + }) .then(() => listChatSessions()) .then((sessions) => { if (titleUpdateNonceRef.current !== currentNonce) return; @@ -487,6 +497,7 @@ export const useAgentChatSession = ({ } setMessages([]); setSessionTitle(undefined); + setIsSessionTitleManuallyEdited(false); setBranchGroups([]); setBranchTransition(null); setSessionId(undefined); @@ -512,6 +523,7 @@ export const useAgentChatSession = ({ sessionIdRef.current = newState.sessionId; setMessages(newState.messages); setSessionTitle(newState.title); + setIsSessionTitleManuallyEdited(newState.isTitleManuallyEdited ?? false); setSessionId(newState.sessionId); setBranchGroups(newState.branchGroups); setChatSessions(sessions); @@ -538,6 +550,7 @@ export const useAgentChatSession = ({ setBranchTransition(null); setMessages(nextState.messages); setSessionTitle(nextState.title); + setIsSessionTitleManuallyEdited(nextState.isTitleManuallyEdited ?? false); setSessionId(nextState.sessionId); setBranchGroups(nextState.branchGroups); setChatSessions(sessions); @@ -571,6 +584,7 @@ export const useAgentChatSession = ({ setBranchTransition(null); setMessages([]); setSessionTitle(undefined); + setIsSessionTitleManuallyEdited(false); setSessionId(undefined); setBranchGroups([]); return; @@ -588,6 +602,7 @@ export const useAgentChatSession = ({ setBranchTransition(null); setMessages(nextState.messages); setSessionTitle(nextState.title); + setIsSessionTitleManuallyEdited(nextState.isTitleManuallyEdited ?? false); setSessionId(nextState.sessionId); setBranchGroups(nextState.branchGroups); setChatSessions(sessionsAfterDelete); @@ -607,6 +622,29 @@ export const useAgentChatSession = ({ [runPrompt], ); + const renameSession = useCallback( + async (targetStorageSessionId: string, nextTitle: string) => { + const normalizedTitle = nextTitle.trim(); + if (!normalizedTitle || isHydrating) return; + + try { + await updateChatSessionTitle(targetStorageSessionId, normalizedTitle, { + isTitleManuallyEdited: true, + }); + const sessions = await listChatSessions(); + setChatSessions(sessions); + + if (storageSessionIdRef.current === targetStorageSessionId) { + setSessionTitle(normalizedTitle); + setIsSessionTitleManuallyEdited(true); + } + } catch (error) { + console.error("[GlobalChatbox] Failed to rename chat session:", error); + } + }, + [isHydrating], + ); + const regenerate = useCallback(async () => { if (isHydrating || isStreaming || messages.length === 0) return; @@ -776,6 +814,7 @@ export const useAgentChatSession = ({ abort, createSession, reset, + renameSession, removeSession, switchSession, };