增加会话标题重命名功能,优化历史面板交互
This commit is contained in:
@@ -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">) => <img {...props} alt={props.alt ?? ""} />,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const renderWithTheme = (ui: React.ReactElement) =>
|
||||||
|
render(<ThemeProvider theme={createTheme()}>{ui}</ThemeProvider>);
|
||||||
|
|
||||||
|
describe("AgentHeader", () => {
|
||||||
|
it("submits a renamed active session title", () => {
|
||||||
|
const onRenameSessionTitle = jest.fn();
|
||||||
|
|
||||||
|
renderWithTheme(
|
||||||
|
<AgentHeader
|
||||||
|
sessionTitle="原始标题"
|
||||||
|
canRenameSessionTitle
|
||||||
|
isStreaming={false}
|
||||||
|
isHistoryOpen={false}
|
||||||
|
onHistoryToggle={jest.fn()}
|
||||||
|
onRenameSessionTitle={onRenameSessionTitle}
|
||||||
|
onNewConversation={jest.fn()}
|
||||||
|
onClose={jest.fn()}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "修改对话标题" }));
|
||||||
|
fireEvent.change(screen.getByPlaceholderText("请输入对话标题"), {
|
||||||
|
target: { value: "更新后的标题" },
|
||||||
|
});
|
||||||
|
fireEvent.click(screen.getByLabelText("确认修改对话标题"));
|
||||||
|
|
||||||
|
expect(onRenameSessionTitle).toHaveBeenCalledWith("更新后的标题");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -8,34 +8,69 @@ import {
|
|||||||
Box,
|
Box,
|
||||||
IconButton,
|
IconButton,
|
||||||
Stack,
|
Stack,
|
||||||
|
TextField,
|
||||||
Tooltip,
|
Tooltip,
|
||||||
Typography,
|
Typography,
|
||||||
alpha,
|
alpha,
|
||||||
useTheme,
|
useTheme,
|
||||||
} from "@mui/material";
|
} 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 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";
|
import HistoryRounded from "@mui/icons-material/HistoryRounded";
|
||||||
|
|
||||||
type AgentHeaderProps = {
|
type AgentHeaderProps = {
|
||||||
sessionTitle?: string;
|
sessionTitle?: string;
|
||||||
|
canRenameSessionTitle?: boolean;
|
||||||
|
isHydrating?: boolean;
|
||||||
isStreaming: boolean;
|
isStreaming: boolean;
|
||||||
isHistoryOpen: boolean;
|
isHistoryOpen: boolean;
|
||||||
onHistoryToggle: () => void;
|
onHistoryToggle: () => void;
|
||||||
|
onRenameSessionTitle?: (title: string) => void;
|
||||||
onNewConversation: () => void;
|
onNewConversation: () => void;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const AgentHeader = ({
|
export const AgentHeader = ({
|
||||||
sessionTitle,
|
sessionTitle,
|
||||||
|
canRenameSessionTitle = false,
|
||||||
|
isHydrating = false,
|
||||||
isStreaming,
|
isStreaming,
|
||||||
isHistoryOpen,
|
isHistoryOpen,
|
||||||
onHistoryToggle,
|
onHistoryToggle,
|
||||||
|
onRenameSessionTitle,
|
||||||
onNewConversation,
|
onNewConversation,
|
||||||
onClose,
|
onClose,
|
||||||
}: AgentHeaderProps) => {
|
}: AgentHeaderProps) => {
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
const displayTitle = sessionTitle?.trim() || "TJWater Agent";
|
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 (
|
return (
|
||||||
<Box
|
<Box
|
||||||
@@ -89,12 +124,92 @@ export const AgentHeader = ({
|
|||||||
"0%": { boxShadow: `0 0 0 0 ${alpha("#ff9800", 0.7)}` },
|
"0%": { boxShadow: `0 0 0 0 ${alpha("#ff9800", 0.7)}` },
|
||||||
"70%": { boxShadow: `0 0 0 6px ${alpha("#ff9800", 0)}` },
|
"70%": { boxShadow: `0 0 0 6px ${alpha("#ff9800", 0)}` },
|
||||||
"100%": { boxShadow: `0 0 0 0 ${alpha("#ff9800", 0)}` },
|
"100%": { boxShadow: `0 0 0 0 ${alpha("#ff9800", 0)}` },
|
||||||
}
|
},
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
<Box sx={{ minWidth: 0 }}>
|
<Box sx={{ minWidth: 0, minHeight: 52, display: "flex", flexDirection: "column", justifyContent: "center" }}>
|
||||||
|
{isEditingTitle ? (
|
||||||
|
<Box>
|
||||||
|
<Stack direction="row" spacing={0.75} alignItems="center" sx={{ width: { xs: "calc(100vw - 256px)", sm: 280 }, transform: "translateY(2px)" }}>
|
||||||
|
<TextField
|
||||||
|
value={draftTitle}
|
||||||
|
onChange={(event) => 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,
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
aria-label="确认"
|
||||||
|
onClick={handleConfirmEditing}
|
||||||
|
disabled={!draftTitle.trim()}
|
||||||
|
sx={{
|
||||||
|
width: 30,
|
||||||
|
height: 30,
|
||||||
|
color: "success.main",
|
||||||
|
bgcolor: alpha(theme.palette.success.main, 0.1),
|
||||||
|
"&:hover": { bgcolor: alpha(theme.palette.success.main, 0.2) },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CheckRounded sx={{ fontSize: 18 }} />
|
||||||
|
</IconButton>
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
aria-label="取消"
|
||||||
|
onClick={handleCancelEditing}
|
||||||
|
sx={{
|
||||||
|
width: 30,
|
||||||
|
height: 30,
|
||||||
|
color: "text.secondary",
|
||||||
|
bgcolor: alpha("#000", 0.05),
|
||||||
|
"&:hover": { bgcolor: alpha("#000", 0.1) },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CloseRounded sx={{ fontSize: 18 }} />
|
||||||
|
</IconButton>
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Stack direction="row" spacing={0.75} alignItems="center" sx={{ minWidth: 0 }}>
|
||||||
<Typography
|
<Typography
|
||||||
variant="h6"
|
variant="h6"
|
||||||
fontWeight={800}
|
fontWeight={800}
|
||||||
@@ -106,11 +221,36 @@ export const AgentHeader = ({
|
|||||||
overflow: "hidden",
|
overflow: "hidden",
|
||||||
textOverflow: "ellipsis",
|
textOverflow: "ellipsis",
|
||||||
whiteSpace: "nowrap",
|
whiteSpace: "nowrap",
|
||||||
maxWidth: { xs: "calc(100vw - 220px)", sm: 320 },
|
maxWidth: { xs: "calc(100vw - 256px)", sm: 284 },
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{displayTitle}
|
{displayTitle}
|
||||||
</Typography>
|
</Typography>
|
||||||
|
{canRenameSessionTitle ? (
|
||||||
|
<Tooltip title="修改对话标题">
|
||||||
|
<span>
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
aria-label="修改对话标题"
|
||||||
|
onClick={handleStartEditing}
|
||||||
|
disabled={isHydrating || isStreaming}
|
||||||
|
sx={{
|
||||||
|
width: 24,
|
||||||
|
height: 24,
|
||||||
|
color: "text.secondary",
|
||||||
|
bgcolor: alpha("#fff", 0.45),
|
||||||
|
"&:hover": {
|
||||||
|
color: "primary.main",
|
||||||
|
bgcolor: alpha(theme.palette.primary.main, 0.08),
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<EditRounded sx={{ fontSize: 16 }} />
|
||||||
|
</IconButton>
|
||||||
|
</span>
|
||||||
|
</Tooltip>
|
||||||
|
) : null}
|
||||||
|
</Stack>
|
||||||
<Typography variant="caption" color="text.secondary" fontWeight={500}>
|
<Typography variant="caption" color="text.secondary" fontWeight={500}>
|
||||||
{isStreaming
|
{isStreaming
|
||||||
? "正在思考分析任务..."
|
? "正在思考分析任务..."
|
||||||
@@ -118,6 +258,8 @@ export const AgentHeader = ({
|
|||||||
? "基于大模型的水力分析引擎"
|
? "基于大模型的水力分析引擎"
|
||||||
: "当前会话标题"}
|
: "当前会话标题"}
|
||||||
</Typography>
|
</Typography>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|
||||||
|
|||||||
@@ -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(<ThemeProvider theme={createTheme()}>{ui}</ThemeProvider>);
|
||||||
|
|
||||||
|
describe("AgentHistoryPanel", () => {
|
||||||
|
it("renames a history session from the list", () => {
|
||||||
|
const onRenameSession = jest.fn();
|
||||||
|
|
||||||
|
renderWithTheme(
|
||||||
|
<AgentHistoryPanel
|
||||||
|
sessions={[
|
||||||
|
{
|
||||||
|
id: "session-1",
|
||||||
|
title: "旧会话标题",
|
||||||
|
createdAt: Date.now(),
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
activeSessionId="session-1"
|
||||||
|
onNewSession={jest.fn()}
|
||||||
|
onRenameSession={onRenameSession}
|
||||||
|
onSelectSession={jest.fn()}
|
||||||
|
onDeleteSession={jest.fn()}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "修改会话标题" }));
|
||||||
|
fireEvent.change(screen.getByPlaceholderText("请输入会话标题"), {
|
||||||
|
target: { value: "新的会话标题" },
|
||||||
|
});
|
||||||
|
fireEvent.click(screen.getByLabelText("确认修改历史会话标题"));
|
||||||
|
|
||||||
|
expect(onRenameSession).toHaveBeenCalledWith("session-1", "新的会话标题");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -18,7 +18,11 @@ import {
|
|||||||
Tooltip,
|
Tooltip,
|
||||||
Typography,
|
Typography,
|
||||||
alpha,
|
alpha,
|
||||||
|
useTheme,
|
||||||
} from "@mui/material";
|
} 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 EditNoteRounded from "@mui/icons-material/EditNoteRounded";
|
||||||
import DeleteOutlineRounded from "@mui/icons-material/DeleteOutlineRounded";
|
import DeleteOutlineRounded from "@mui/icons-material/DeleteOutlineRounded";
|
||||||
import ChatBubbleOutlineRounded from "@mui/icons-material/ChatBubbleOutlineRounded";
|
import ChatBubbleOutlineRounded from "@mui/icons-material/ChatBubbleOutlineRounded";
|
||||||
@@ -31,6 +35,7 @@ type AgentHistoryPanelProps = {
|
|||||||
activeSessionId?: string;
|
activeSessionId?: string;
|
||||||
isHydrating?: boolean;
|
isHydrating?: boolean;
|
||||||
onNewSession: () => void;
|
onNewSession: () => void;
|
||||||
|
onRenameSession: (sessionId: string, title: string) => void;
|
||||||
onSelectSession: (sessionId: string) => void;
|
onSelectSession: (sessionId: string) => void;
|
||||||
onDeleteSession: (sessionId: string) => void;
|
onDeleteSession: (sessionId: string) => void;
|
||||||
};
|
};
|
||||||
@@ -68,14 +73,19 @@ const getSessionGroupLabel = (timestamp: number) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const AgentHistoryPanel = ({
|
export const AgentHistoryPanel = ({
|
||||||
|
|
||||||
sessions,
|
sessions,
|
||||||
activeSessionId,
|
activeSessionId,
|
||||||
isHydrating = false,
|
isHydrating = false,
|
||||||
onNewSession,
|
onNewSession,
|
||||||
|
onRenameSession,
|
||||||
onSelectSession,
|
onSelectSession,
|
||||||
onDeleteSession,
|
onDeleteSession,
|
||||||
}: AgentHistoryPanelProps) => {
|
}: AgentHistoryPanelProps) => {
|
||||||
|
const theme = useTheme();
|
||||||
const [keyword, setKeyword] = React.useState("");
|
const [keyword, setKeyword] = React.useState("");
|
||||||
|
const [editingSessionId, setEditingSessionId] = React.useState<string | null>(null);
|
||||||
|
const [draftTitle, setDraftTitle] = React.useState("");
|
||||||
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = React.useState(false);
|
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = React.useState(false);
|
||||||
const [pendingDeleteSessionId, setPendingDeleteSessionId] = React.useState<string | null>(null);
|
const [pendingDeleteSessionId, setPendingDeleteSessionId] = React.useState<string | null>(null);
|
||||||
|
|
||||||
@@ -105,6 +115,23 @@ export const AgentHistoryPanel = ({
|
|||||||
(session) => session.id === pendingDeleteSessionId,
|
(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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<Paper
|
<Paper
|
||||||
@@ -240,7 +267,10 @@ export const AgentHistoryPanel = ({
|
|||||||
<Paper
|
<Paper
|
||||||
key={session.id}
|
key={session.id}
|
||||||
elevation={0}
|
elevation={0}
|
||||||
onClick={() => onSelectSession(session.id)}
|
onClick={() => {
|
||||||
|
if (editingSessionId === session.id) return;
|
||||||
|
onSelectSession(session.id);
|
||||||
|
}}
|
||||||
sx={{
|
sx={{
|
||||||
px: 1.25,
|
px: 1.25,
|
||||||
py: 1,
|
py: 1,
|
||||||
@@ -259,6 +289,92 @@ export const AgentHistoryPanel = ({
|
|||||||
>
|
>
|
||||||
<Stack direction="row" spacing={1} alignItems="flex-start">
|
<Stack direction="row" spacing={1} alignItems="flex-start">
|
||||||
<Box sx={{ flex: 1, minWidth: 0 }}>
|
<Box sx={{ flex: 1, minWidth: 0 }}>
|
||||||
|
{editingSessionId === session.id ? (
|
||||||
|
<Stack direction="row" spacing={0.5} alignItems="center" sx={{ minHeight: 46 }}>
|
||||||
|
<TextField
|
||||||
|
value={draftTitle}
|
||||||
|
onChange={(event) => 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,
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
aria-label="确认"
|
||||||
|
onClick={(event) => {
|
||||||
|
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) },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CheckRounded sx={{ fontSize: 16 }} />
|
||||||
|
</IconButton>
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
aria-label="取消"
|
||||||
|
onClick={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
handleCancelRename();
|
||||||
|
}}
|
||||||
|
sx={{
|
||||||
|
width: 28,
|
||||||
|
height: 28,
|
||||||
|
color: "text.secondary",
|
||||||
|
bgcolor: alpha("#000", 0.05),
|
||||||
|
"&:hover": { bgcolor: alpha("#000", 0.1) },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CloseRounded sx={{ fontSize: 16 }} />
|
||||||
|
</IconButton>
|
||||||
|
</Stack>
|
||||||
|
) : (
|
||||||
|
<Box sx={{ minHeight: 46, display: "flex", flexDirection: "column", justifyContent: "center" }}>
|
||||||
<Typography
|
<Typography
|
||||||
variant="body2"
|
variant="body2"
|
||||||
fontWeight={isActive ? 800 : 700}
|
fontWeight={isActive ? 800 : 700}
|
||||||
@@ -277,7 +393,34 @@ export const AgentHistoryPanel = ({
|
|||||||
{formatRelativeDate(session.updatedAt)}
|
{formatRelativeDate(session.updatedAt)}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Stack direction="row" spacing={0.25} sx={{ display: editingSessionId === session.id ? 'none' : 'flex' }}>
|
||||||
|
<Tooltip title="修改会话标题">
|
||||||
|
<span>
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
aria-label="修改会话标题"
|
||||||
|
onClick={(event) => {
|
||||||
|
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),
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<EditRounded sx={{ fontSize: 16 }} />
|
||||||
|
</IconButton>
|
||||||
|
</span>
|
||||||
|
</Tooltip>
|
||||||
<Tooltip title="删除会话">
|
<Tooltip title="删除会话">
|
||||||
<span>
|
<span>
|
||||||
<IconButton
|
<IconButton
|
||||||
@@ -303,6 +446,7 @@ export const AgentHistoryPanel = ({
|
|||||||
</span>
|
</span>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
</Stack>
|
||||||
</Paper>
|
</Paper>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -70,6 +70,7 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
|
|||||||
cycleBranch,
|
cycleBranch,
|
||||||
abort,
|
abort,
|
||||||
createSession,
|
createSession,
|
||||||
|
renameSession,
|
||||||
removeSession,
|
removeSession,
|
||||||
switchSession,
|
switchSession,
|
||||||
} = useAgentChatSession({
|
} = useAgentChatSession({
|
||||||
@@ -134,6 +135,21 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
|
|||||||
[removeSession],
|
[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) => {
|
const handleMouseDown = useCallback((event: React.MouseEvent) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
setIsResizing(true);
|
setIsResizing(true);
|
||||||
@@ -231,9 +247,12 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
|
|||||||
|
|
||||||
<AgentHeader
|
<AgentHeader
|
||||||
sessionTitle={sessionTitle}
|
sessionTitle={sessionTitle}
|
||||||
|
canRenameSessionTitle={Boolean(activeStorageSessionId)}
|
||||||
|
isHydrating={isHydrating}
|
||||||
isStreaming={isStreaming}
|
isStreaming={isStreaming}
|
||||||
isHistoryOpen={isHistoryOpen}
|
isHistoryOpen={isHistoryOpen}
|
||||||
onHistoryToggle={handleHistoryToggle}
|
onHistoryToggle={handleHistoryToggle}
|
||||||
|
onRenameSessionTitle={handleRenameActiveSession}
|
||||||
onNewConversation={handleNewConversation}
|
onNewConversation={handleNewConversation}
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
/>
|
/>
|
||||||
@@ -277,6 +296,7 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
|
|||||||
handleSelectSession(id);
|
handleSelectSession(id);
|
||||||
setIsHistoryOpen(false);
|
setIsHistoryOpen(false);
|
||||||
}}
|
}}
|
||||||
|
onRenameSession={handleRenameSession}
|
||||||
onDeleteSession={handleDeleteSession}
|
onDeleteSession={handleDeleteSession}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -75,6 +75,7 @@ export type LegacyPersistedChatState = {
|
|||||||
export type ChatSessionRecord = {
|
export type ChatSessionRecord = {
|
||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
|
isTitleManuallyEdited?: boolean;
|
||||||
createdAt: number;
|
createdAt: number;
|
||||||
updatedAt: number;
|
updatedAt: number;
|
||||||
sessionId?: string;
|
sessionId?: string;
|
||||||
@@ -98,6 +99,7 @@ export type ChatStorageMeta = {
|
|||||||
export type LoadedChatState = {
|
export type LoadedChatState = {
|
||||||
storageSessionId?: string;
|
storageSessionId?: string;
|
||||||
title?: string;
|
title?: string;
|
||||||
|
isTitleManuallyEdited?: boolean;
|
||||||
messages: Message[];
|
messages: Message[];
|
||||||
sessionId?: string;
|
sessionId?: string;
|
||||||
branchGroups: BranchGroup[];
|
branchGroups: BranchGroup[];
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ type ChatDB = DBSchema & {
|
|||||||
const emptyLoadedChatState = (): LoadedChatState => ({
|
const emptyLoadedChatState = (): LoadedChatState => ({
|
||||||
storageSessionId: undefined,
|
storageSessionId: undefined,
|
||||||
title: undefined,
|
title: undefined,
|
||||||
|
isTitleManuallyEdited: false,
|
||||||
messages: [],
|
messages: [],
|
||||||
sessionId: undefined,
|
sessionId: undefined,
|
||||||
branchGroups: [],
|
branchGroups: [],
|
||||||
@@ -55,6 +56,7 @@ const toLoadedChatState = (session: ChatSessionRecord | undefined): LoadedChatSt
|
|||||||
return {
|
return {
|
||||||
storageSessionId: session.id,
|
storageSessionId: session.id,
|
||||||
title: session.title,
|
title: session.title,
|
||||||
|
isTitleManuallyEdited: session.isTitleManuallyEdited ?? false,
|
||||||
messages: sanitizeMessages(session.messages),
|
messages: sanitizeMessages(session.messages),
|
||||||
sessionId: session.sessionId,
|
sessionId: session.sessionId,
|
||||||
branchGroups: sanitizeBranchGroups(session.branchGroups),
|
branchGroups: sanitizeBranchGroups(session.branchGroups),
|
||||||
@@ -163,6 +165,7 @@ const migrateLegacyLocalStorage = async () => {
|
|||||||
const sessionRecord: ChatSessionRecord = {
|
const sessionRecord: ChatSessionRecord = {
|
||||||
id: createId(),
|
id: createId(),
|
||||||
title: "新对话",
|
title: "新对话",
|
||||||
|
isTitleManuallyEdited: false,
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
sessionId: legacyState.sessionId,
|
sessionId: legacyState.sessionId,
|
||||||
@@ -241,6 +244,7 @@ export const saveActiveChatState = async (
|
|||||||
const nextRecord: ChatSessionRecord = {
|
const nextRecord: ChatSessionRecord = {
|
||||||
id: storageSessionId,
|
id: storageSessionId,
|
||||||
title: finalTitle,
|
title: finalTitle,
|
||||||
|
isTitleManuallyEdited: state.isTitleManuallyEdited ?? existingSession?.isTitleManuallyEdited ?? false,
|
||||||
createdAt: existingSession?.createdAt ?? now,
|
createdAt: existingSession?.createdAt ?? now,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
sessionId: state.sessionId,
|
sessionId: state.sessionId,
|
||||||
@@ -272,6 +276,9 @@ export const listChatSessions = async (): Promise<ChatSessionSummary[]> => {
|
|||||||
export const updateChatSessionTitle = async (
|
export const updateChatSessionTitle = async (
|
||||||
storageSessionId: string,
|
storageSessionId: string,
|
||||||
title: string,
|
title: string,
|
||||||
|
options?: {
|
||||||
|
isTitleManuallyEdited?: boolean;
|
||||||
|
},
|
||||||
): Promise<void> => {
|
): Promise<void> => {
|
||||||
if (typeof window === "undefined") return;
|
if (typeof window === "undefined") return;
|
||||||
|
|
||||||
@@ -285,6 +292,8 @@ export const updateChatSessionTitle = async (
|
|||||||
await db.put(SESSION_STORE, {
|
await db.put(SESSION_STORE, {
|
||||||
...session,
|
...session,
|
||||||
title: normalizedTitle,
|
title: normalizedTitle,
|
||||||
|
isTitleManuallyEdited:
|
||||||
|
options?.isTitleManuallyEdited ?? session.isTitleManuallyEdited ?? false,
|
||||||
updatedAt: Date.now(),
|
updatedAt: Date.now(),
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -298,6 +307,7 @@ export const createEmptyChatSession = async (): Promise<LoadedChatState> => {
|
|||||||
const session: ChatSessionRecord = {
|
const session: ChatSessionRecord = {
|
||||||
id: createId(),
|
id: createId(),
|
||||||
title: "新对话",
|
title: "新对话",
|
||||||
|
isTitleManuallyEdited: false,
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
sessionId: undefined,
|
sessionId: undefined,
|
||||||
|
|||||||
@@ -146,6 +146,7 @@ export const useAgentChatSession = ({
|
|||||||
|
|
||||||
const [messages, setMessages] = useState<Message[]>([]);
|
const [messages, setMessages] = useState<Message[]>([]);
|
||||||
const [sessionTitle, setSessionTitle] = useState<string | undefined>(undefined);
|
const [sessionTitle, setSessionTitle] = useState<string | undefined>(undefined);
|
||||||
|
const [isSessionTitleManuallyEdited, setIsSessionTitleManuallyEdited] = useState(false);
|
||||||
const [sessionId, setSessionId] = useState<string | undefined>(undefined);
|
const [sessionId, setSessionId] = useState<string | undefined>(undefined);
|
||||||
const [branchGroups, setBranchGroups] = useState<BranchGroup[]>([]);
|
const [branchGroups, setBranchGroups] = useState<BranchGroup[]>([]);
|
||||||
const [chatSessions, setChatSessions] = useState<ChatSessionSummary[]>([]);
|
const [chatSessions, setChatSessions] = useState<ChatSessionSummary[]>([]);
|
||||||
@@ -154,6 +155,7 @@ export const useAgentChatSession = ({
|
|||||||
const [isHydrating, setIsHydrating] = useState(true);
|
const [isHydrating, setIsHydrating] = useState(true);
|
||||||
const abortRef = useRef<AbortController | null>(null);
|
const abortRef = useRef<AbortController | null>(null);
|
||||||
const sessionIdRef = useRef<string | undefined>(undefined);
|
const sessionIdRef = useRef<string | undefined>(undefined);
|
||||||
|
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);
|
||||||
|
|
||||||
@@ -161,6 +163,10 @@ export const useAgentChatSession = ({
|
|||||||
sessionIdRef.current = sessionId;
|
sessionIdRef.current = sessionId;
|
||||||
}, [sessionId]);
|
}, [sessionId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
isSessionTitleManuallyEditedRef.current = isSessionTitleManuallyEdited;
|
||||||
|
}, [isSessionTitleManuallyEdited]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
|
|
||||||
@@ -180,6 +186,7 @@ export const useAgentChatSession = ({
|
|||||||
|
|
||||||
setMessages(loadedState.messages);
|
setMessages(loadedState.messages);
|
||||||
setSessionTitle(loadedState.title);
|
setSessionTitle(loadedState.title);
|
||||||
|
setIsSessionTitleManuallyEdited(loadedState.isTitleManuallyEdited ?? false);
|
||||||
setSessionId(loadedState.sessionId);
|
setSessionId(loadedState.sessionId);
|
||||||
setBranchGroups(loadedState.branchGroups);
|
setBranchGroups(loadedState.branchGroups);
|
||||||
setChatSessions(sessions);
|
setChatSessions(sessions);
|
||||||
@@ -207,6 +214,7 @@ export const useAgentChatSession = ({
|
|||||||
const state: LoadedChatState = {
|
const state: LoadedChatState = {
|
||||||
storageSessionId: storageSessionIdRef.current,
|
storageSessionId: storageSessionIdRef.current,
|
||||||
title: sessionTitle,
|
title: sessionTitle,
|
||||||
|
isTitleManuallyEdited: isSessionTitleManuallyEdited,
|
||||||
messages,
|
messages,
|
||||||
sessionId,
|
sessionId,
|
||||||
branchGroups,
|
branchGroups,
|
||||||
@@ -230,7 +238,7 @@ export const useAgentChatSession = ({
|
|||||||
return () => {
|
return () => {
|
||||||
window.clearTimeout(persistTimer);
|
window.clearTimeout(persistTimer);
|
||||||
};
|
};
|
||||||
}, [branchGroups, isHydrating, messages, sessionId, sessionTitle]);
|
}, [branchGroups, isHydrating, isSessionTitleManuallyEdited, messages, sessionId, sessionTitle]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setBranchGroups((prev) => {
|
setBranchGroups((prev) => {
|
||||||
@@ -354,12 +362,14 @@ export const useAgentChatSession = ({
|
|||||||
});
|
});
|
||||||
} else if (event.type === "session_title") {
|
} else if (event.type === "session_title") {
|
||||||
const nextTitle = event.title.trim();
|
const nextTitle = event.title.trim();
|
||||||
if (nextTitle) {
|
if (nextTitle && !isSessionTitleManuallyEditedRef.current) {
|
||||||
setSessionTitle(nextTitle);
|
setSessionTitle(nextTitle);
|
||||||
const currentStorageSessionId = storageSessionIdRef.current;
|
const currentStorageSessionId = storageSessionIdRef.current;
|
||||||
if (currentStorageSessionId) {
|
if (currentStorageSessionId) {
|
||||||
const currentNonce = ++titleUpdateNonceRef.current;
|
const currentNonce = ++titleUpdateNonceRef.current;
|
||||||
void updateChatSessionTitle(currentStorageSessionId, nextTitle)
|
void updateChatSessionTitle(currentStorageSessionId, nextTitle, {
|
||||||
|
isTitleManuallyEdited: false,
|
||||||
|
})
|
||||||
.then(() => listChatSessions())
|
.then(() => listChatSessions())
|
||||||
.then((sessions) => {
|
.then((sessions) => {
|
||||||
if (titleUpdateNonceRef.current !== currentNonce) return;
|
if (titleUpdateNonceRef.current !== currentNonce) return;
|
||||||
@@ -487,6 +497,7 @@ export const useAgentChatSession = ({
|
|||||||
}
|
}
|
||||||
setMessages([]);
|
setMessages([]);
|
||||||
setSessionTitle(undefined);
|
setSessionTitle(undefined);
|
||||||
|
setIsSessionTitleManuallyEdited(false);
|
||||||
setBranchGroups([]);
|
setBranchGroups([]);
|
||||||
setBranchTransition(null);
|
setBranchTransition(null);
|
||||||
setSessionId(undefined);
|
setSessionId(undefined);
|
||||||
@@ -512,6 +523,7 @@ export const useAgentChatSession = ({
|
|||||||
sessionIdRef.current = newState.sessionId;
|
sessionIdRef.current = newState.sessionId;
|
||||||
setMessages(newState.messages);
|
setMessages(newState.messages);
|
||||||
setSessionTitle(newState.title);
|
setSessionTitle(newState.title);
|
||||||
|
setIsSessionTitleManuallyEdited(newState.isTitleManuallyEdited ?? false);
|
||||||
setSessionId(newState.sessionId);
|
setSessionId(newState.sessionId);
|
||||||
setBranchGroups(newState.branchGroups);
|
setBranchGroups(newState.branchGroups);
|
||||||
setChatSessions(sessions);
|
setChatSessions(sessions);
|
||||||
@@ -538,6 +550,7 @@ export const useAgentChatSession = ({
|
|||||||
setBranchTransition(null);
|
setBranchTransition(null);
|
||||||
setMessages(nextState.messages);
|
setMessages(nextState.messages);
|
||||||
setSessionTitle(nextState.title);
|
setSessionTitle(nextState.title);
|
||||||
|
setIsSessionTitleManuallyEdited(nextState.isTitleManuallyEdited ?? false);
|
||||||
setSessionId(nextState.sessionId);
|
setSessionId(nextState.sessionId);
|
||||||
setBranchGroups(nextState.branchGroups);
|
setBranchGroups(nextState.branchGroups);
|
||||||
setChatSessions(sessions);
|
setChatSessions(sessions);
|
||||||
@@ -571,6 +584,7 @@ export const useAgentChatSession = ({
|
|||||||
setBranchTransition(null);
|
setBranchTransition(null);
|
||||||
setMessages([]);
|
setMessages([]);
|
||||||
setSessionTitle(undefined);
|
setSessionTitle(undefined);
|
||||||
|
setIsSessionTitleManuallyEdited(false);
|
||||||
setSessionId(undefined);
|
setSessionId(undefined);
|
||||||
setBranchGroups([]);
|
setBranchGroups([]);
|
||||||
return;
|
return;
|
||||||
@@ -588,6 +602,7 @@ export const useAgentChatSession = ({
|
|||||||
setBranchTransition(null);
|
setBranchTransition(null);
|
||||||
setMessages(nextState.messages);
|
setMessages(nextState.messages);
|
||||||
setSessionTitle(nextState.title);
|
setSessionTitle(nextState.title);
|
||||||
|
setIsSessionTitleManuallyEdited(nextState.isTitleManuallyEdited ?? false);
|
||||||
setSessionId(nextState.sessionId);
|
setSessionId(nextState.sessionId);
|
||||||
setBranchGroups(nextState.branchGroups);
|
setBranchGroups(nextState.branchGroups);
|
||||||
setChatSessions(sessionsAfterDelete);
|
setChatSessions(sessionsAfterDelete);
|
||||||
@@ -607,6 +622,29 @@ export const useAgentChatSession = ({
|
|||||||
[runPrompt],
|
[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 () => {
|
const regenerate = useCallback(async () => {
|
||||||
if (isHydrating || isStreaming || messages.length === 0) return;
|
if (isHydrating || isStreaming || messages.length === 0) return;
|
||||||
|
|
||||||
@@ -776,6 +814,7 @@ export const useAgentChatSession = ({
|
|||||||
abort,
|
abort,
|
||||||
createSession,
|
createSession,
|
||||||
reset,
|
reset,
|
||||||
|
renameSession,
|
||||||
removeSession,
|
removeSession,
|
||||||
switchSession,
|
switchSession,
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user