重构会话标题编辑和删除确认逻辑;重构历史会话时间记录
This commit is contained in:
@@ -33,7 +33,7 @@ describe("AgentHeader", () => {
|
|||||||
fireEvent.change(screen.getByPlaceholderText("请输入对话标题"), {
|
fireEvent.change(screen.getByPlaceholderText("请输入对话标题"), {
|
||||||
target: { value: "更新后的标题" },
|
target: { value: "更新后的标题" },
|
||||||
});
|
});
|
||||||
fireEvent.click(screen.getByLabelText("确认修改对话标题"));
|
fireEvent.click(screen.getByLabelText("确认"));
|
||||||
|
|
||||||
expect(onRenameSessionTitle).toHaveBeenCalledWith("更新后的标题");
|
expect(onRenameSessionTitle).toHaveBeenCalledWith("更新后的标题");
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -129,10 +129,9 @@ export const AgentHeader = ({
|
|||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
<Box sx={{ minWidth: 0, minHeight: 52, display: "flex", flexDirection: "column", justifyContent: "center" }}>
|
<Box sx={{ minWidth: 0 }}>
|
||||||
{isEditingTitle ? (
|
{isEditingTitle ? (
|
||||||
<Box>
|
<Stack direction="row" spacing={0.75} alignItems="center" sx={{ width: { xs: "calc(100vw - 256px)", sm: 280 } }}>
|
||||||
<Stack direction="row" spacing={0.75} alignItems="center" sx={{ width: { xs: "calc(100vw - 256px)", sm: 280 }, transform: "translateY(2px)" }}>
|
|
||||||
<TextField
|
<TextField
|
||||||
value={draftTitle}
|
value={draftTitle}
|
||||||
onChange={(event) => setDraftTitle(event.target.value)}
|
onChange={(event) => setDraftTitle(event.target.value)}
|
||||||
@@ -152,27 +151,35 @@ export const AgentHeader = ({
|
|||||||
flex: 1,
|
flex: 1,
|
||||||
minWidth: 0,
|
minWidth: 0,
|
||||||
"& .MuiOutlinedInput-root": {
|
"& .MuiOutlinedInput-root": {
|
||||||
height: 34,
|
padding: "6px 8px",
|
||||||
bgcolor: alpha("#fff", 0.7),
|
bgcolor: "transparent",
|
||||||
borderRadius: 1.5,
|
borderRadius: 1.5,
|
||||||
transition: "all 0.2s ease-in-out",
|
transition: "all 0.2s ease-in-out",
|
||||||
|
"&.Mui-focused": {
|
||||||
|
bgcolor: alpha("#fff", 0.6),
|
||||||
|
boxShadow: `0 2px 10px ${alpha("#000", 0.05)}`,
|
||||||
|
},
|
||||||
"& fieldset": {
|
"& fieldset": {
|
||||||
borderColor: alpha("#000", 0.08),
|
borderColor: "transparent",
|
||||||
},
|
},
|
||||||
"&:hover fieldset": {
|
"&:hover fieldset": {
|
||||||
borderColor: alpha(theme.palette.primary.main, 0.4),
|
borderColor: alpha(theme.palette.primary.main, 0.2),
|
||||||
},
|
},
|
||||||
"&.Mui-focused fieldset": {
|
"&.Mui-focused fieldset": {
|
||||||
borderColor: theme.palette.primary.main,
|
borderColor: alpha(theme.palette.primary.main, 0.5),
|
||||||
borderWidth: "1.5px",
|
borderWidth: "1px",
|
||||||
boxShadow: `0 0 0 3px ${alpha(theme.palette.primary.main, 0.1)}`,
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"& .MuiInputBase-input": {
|
"& .MuiInputBase-input": {
|
||||||
padding: "4px 12px",
|
padding: 0,
|
||||||
fontSize: "1.05rem",
|
height: "auto",
|
||||||
fontWeight: 700,
|
fontSize: "1.25rem",
|
||||||
color: theme.palette.text.primary,
|
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 = ({
|
|||||||
<CloseRounded sx={{ fontSize: 18 }} />
|
<CloseRounded sx={{ fontSize: 18 }} />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Box>
|
|
||||||
) : (
|
) : (
|
||||||
<>
|
|
||||||
<Stack direction="row" spacing={0.75} alignItems="center" sx={{ minWidth: 0 }}>
|
<Stack direction="row" spacing={0.75} alignItems="center" sx={{ minWidth: 0 }}>
|
||||||
<Typography
|
<Typography
|
||||||
variant="h6"
|
variant="h6"
|
||||||
fontWeight={800}
|
fontWeight={800}
|
||||||
sx={{
|
sx={{
|
||||||
background: `linear-gradient(90deg, #01579b, #00838f)`,
|
background: `linear-gradient(90deg, #01579b, #00838f)`,
|
||||||
backgroundClip: "text",
|
WebkitBackgroundClip: "text",
|
||||||
color: "transparent",
|
WebkitTextFillColor: "transparent",
|
||||||
letterSpacing: -0.3,
|
letterSpacing: -0.3,
|
||||||
overflow: "hidden",
|
overflow: "hidden",
|
||||||
textOverflow: "ellipsis",
|
textOverflow: "ellipsis",
|
||||||
whiteSpace: "nowrap",
|
whiteSpace: "nowrap",
|
||||||
maxWidth: { xs: "calc(100vw - 256px)", sm: 284 },
|
maxWidth: { xs: "calc(100vw - 256px)", sm: 284 },
|
||||||
|
px: "8px",
|
||||||
|
lineHeight: 1.2,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{displayTitle}
|
{displayTitle}
|
||||||
@@ -235,8 +242,8 @@ export const AgentHeader = ({
|
|||||||
onClick={handleStartEditing}
|
onClick={handleStartEditing}
|
||||||
disabled={isHydrating || isStreaming}
|
disabled={isHydrating || isStreaming}
|
||||||
sx={{
|
sx={{
|
||||||
width: 24,
|
width: 30,
|
||||||
height: 24,
|
height: 30,
|
||||||
color: "text.secondary",
|
color: "text.secondary",
|
||||||
bgcolor: alpha("#fff", 0.45),
|
bgcolor: alpha("#fff", 0.45),
|
||||||
"&:hover": {
|
"&:hover": {
|
||||||
@@ -245,20 +252,12 @@ export const AgentHeader = ({
|
|||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<EditRounded sx={{ fontSize: 16 }} />
|
<EditRounded sx={{ fontSize: 18 }} />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</span>
|
</span>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
) : null}
|
) : null}
|
||||||
</Stack>
|
</Stack>
|
||||||
<Typography variant="caption" color="text.secondary" fontWeight={500}>
|
|
||||||
{isStreaming
|
|
||||||
? "正在思考分析任务..."
|
|
||||||
: displayTitle === "TJWater Agent"
|
|
||||||
? "基于大模型的水力分析引擎"
|
|
||||||
: "当前会话标题"}
|
|
||||||
</Typography>
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|||||||
@@ -85,7 +85,6 @@ export const AgentHistoryPanel = ({
|
|||||||
const [keyword, setKeyword] = React.useState("");
|
const [keyword, setKeyword] = React.useState("");
|
||||||
const [editingSessionId, setEditingSessionId] = React.useState<string | null>(null);
|
const [editingSessionId, setEditingSessionId] = React.useState<string | null>(null);
|
||||||
const [draftTitle, setDraftTitle] = React.useState("");
|
const [draftTitle, setDraftTitle] = React.useState("");
|
||||||
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = React.useState(false);
|
|
||||||
const [pendingDeleteSessionId, setPendingDeleteSessionId] = React.useState<string | null>(null);
|
const [pendingDeleteSessionId, setPendingDeleteSessionId] = React.useState<string | null>(null);
|
||||||
|
|
||||||
const filteredSessions = React.useMemo(() => {
|
const filteredSessions = React.useMemo(() => {
|
||||||
@@ -300,7 +299,7 @@ export const AgentHistoryPanel = ({
|
|||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Stack direction="row" spacing={1} alignItems="flex-start">
|
<Stack direction="row" spacing={1} alignItems="center">
|
||||||
<Box sx={{ flex: 1, minWidth: 0 }}>
|
<Box sx={{ flex: 1, minWidth: 0 }}>
|
||||||
{editingSessionId === session.id ? (
|
{editingSessionId === session.id ? (
|
||||||
<Stack direction="row" spacing={0.5} alignItems="center" sx={{ minHeight: 46 }}>
|
<Stack direction="row" spacing={0.5} alignItems="center" sx={{ minHeight: 46 }}>
|
||||||
@@ -386,6 +385,38 @@ export const AgentHistoryPanel = ({
|
|||||||
<CloseRounded sx={{ fontSize: 16 }} />
|
<CloseRounded sx={{ fontSize: 16 }} />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
) : pendingDeleteSessionId === session.id ? (
|
||||||
|
<Stack direction="row" spacing={0.75} alignItems="center" sx={{ minHeight: 46 }}>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
width: 20,
|
||||||
|
height: 20,
|
||||||
|
borderRadius: "50%",
|
||||||
|
bgcolor: alpha("#ef5350", 0.15),
|
||||||
|
color: "#ef5350",
|
||||||
|
flexShrink: 0
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<WarningRounded sx={{ fontSize: 13 }} />
|
||||||
|
</Box>
|
||||||
|
<Typography
|
||||||
|
variant="body2"
|
||||||
|
fontWeight={800}
|
||||||
|
color="error.main"
|
||||||
|
sx={{
|
||||||
|
flex: 1,
|
||||||
|
minWidth: 0,
|
||||||
|
overflow: "hidden",
|
||||||
|
textOverflow: "ellipsis",
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
确认删除此会话?
|
||||||
|
</Typography>
|
||||||
|
</Stack>
|
||||||
) : (
|
) : (
|
||||||
<Box sx={{ minHeight: 46, display: "flex", flexDirection: "column", justifyContent: "center" }}>
|
<Box sx={{ minHeight: 46, display: "flex", flexDirection: "column", justifyContent: "center" }}>
|
||||||
<Typography
|
<Typography
|
||||||
@@ -409,7 +440,8 @@ export const AgentHistoryPanel = ({
|
|||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Stack direction="row" spacing={0.25} sx={{ display: editingSessionId === session.id ? 'none' : 'flex' }}>
|
{!(editingSessionId === session.id || pendingDeleteSessionId === session.id) && (
|
||||||
|
<Stack direction="row" spacing={0.25}>
|
||||||
<Tooltip title="修改会话标题">
|
<Tooltip title="修改会话标题">
|
||||||
<span>
|
<span>
|
||||||
<IconButton
|
<IconButton
|
||||||
@@ -421,8 +453,8 @@ export const AgentHistoryPanel = ({
|
|||||||
}}
|
}}
|
||||||
disabled={isHydrating || editingSessionId === session.id}
|
disabled={isHydrating || editingSessionId === session.id}
|
||||||
sx={{
|
sx={{
|
||||||
width: 24,
|
width: 28,
|
||||||
height: 24,
|
height: 28,
|
||||||
color: "text.secondary",
|
color: "text.secondary",
|
||||||
"&:hover": {
|
"&:hover": {
|
||||||
color: "primary.main",
|
color: "primary.main",
|
||||||
@@ -442,11 +474,11 @@ export const AgentHistoryPanel = ({
|
|||||||
onClick={(event) => {
|
onClick={(event) => {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
setPendingDeleteSessionId(session.id);
|
setPendingDeleteSessionId(session.id);
|
||||||
setIsDeleteDialogOpen(true);
|
|
||||||
}}
|
}}
|
||||||
|
disabled={isHydrating}
|
||||||
sx={{
|
sx={{
|
||||||
width: 24,
|
width: 28,
|
||||||
height: 24,
|
height: 28,
|
||||||
color: "text.secondary",
|
color: "text.secondary",
|
||||||
"&:hover": {
|
"&:hover": {
|
||||||
color: "error.main",
|
color: "error.main",
|
||||||
@@ -459,6 +491,48 @@ export const AgentHistoryPanel = ({
|
|||||||
</span>
|
</span>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{pendingDeleteSessionId === session.id && (
|
||||||
|
<Stack direction="row" spacing={0.5} alignItems="center">
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
aria-label="确认删除"
|
||||||
|
onClick={(event) => {
|
||||||
|
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) },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CheckRounded sx={{ fontSize: 16 }} />
|
||||||
|
</IconButton>
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
aria-label="取消删除"
|
||||||
|
onClick={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
setPendingDeleteSessionId(null);
|
||||||
|
}}
|
||||||
|
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>
|
||||||
|
)}
|
||||||
|
|
||||||
</Stack>
|
</Stack>
|
||||||
</Paper>
|
</Paper>
|
||||||
);
|
);
|
||||||
@@ -470,97 +544,6 @@ export const AgentHistoryPanel = ({
|
|||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
</Paper>
|
</Paper>
|
||||||
|
|
||||||
<Dialog
|
|
||||||
open={isDeleteDialogOpen}
|
|
||||||
onClose={() => 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,
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<DialogTitle sx={{ display: "flex", alignItems: "center", gap: 1.5, pb: 1, pt: 3, px: 3 }}>
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "center",
|
|
||||||
width: 40,
|
|
||||||
height: 40,
|
|
||||||
borderRadius: "50%",
|
|
||||||
bgcolor: alpha("#ef5350", 0.12),
|
|
||||||
color: "#ef5350",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<WarningRounded sx={{ fontSize: 22 }} />
|
|
||||||
</Box>
|
|
||||||
<Typography component="span" variant="h6" fontWeight={800} color="text.primary">
|
|
||||||
删除确认
|
|
||||||
</Typography>
|
|
||||||
</DialogTitle>
|
|
||||||
<DialogContent sx={{ px: 3, pb: 2 }}>
|
|
||||||
<DialogContentText color="text.secondary" sx={{ fontSize: "0.95rem" }}>
|
|
||||||
确定要删除
|
|
||||||
{pendingDeleteSession ? (
|
|
||||||
<Typography component="span" fontWeight={700} color="text.primary">
|
|
||||||
“{pendingDeleteSession.title}”
|
|
||||||
</Typography>
|
|
||||||
) : (
|
|
||||||
"该会话"
|
|
||||||
)}
|
|
||||||
吗?
|
|
||||||
<br />
|
|
||||||
此操作不可撤销,删除后聊天记录将永久丢失。
|
|
||||||
</DialogContentText>
|
|
||||||
</DialogContent>
|
|
||||||
<DialogActions sx={{ px: 3, pb: 3, pt: 1 }}>
|
|
||||||
<Button
|
|
||||||
onClick={() => setIsDeleteDialogOpen(false)}
|
|
||||||
sx={{
|
|
||||||
color: "text.secondary",
|
|
||||||
fontWeight: 600,
|
|
||||||
borderRadius: 2.5,
|
|
||||||
px: 2.5,
|
|
||||||
"&:hover": { bgcolor: alpha("#000", 0.04) },
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
取消
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="contained"
|
|
||||||
onClick={() => {
|
|
||||||
if (pendingDeleteSessionId) {
|
|
||||||
onDeleteSession(pendingDeleteSessionId);
|
|
||||||
}
|
|
||||||
setIsDeleteDialogOpen(false);
|
|
||||||
}}
|
|
||||||
sx={{
|
|
||||||
bgcolor: "#ef5350",
|
|
||||||
color: "#fff",
|
|
||||||
fontWeight: 700,
|
|
||||||
borderRadius: 2.5,
|
|
||||||
px: 3,
|
|
||||||
boxShadow: `0 4px 12px ${alpha("#ef5350", 0.3)}`,
|
|
||||||
"&:hover": {
|
|
||||||
bgcolor: "#e53935",
|
|
||||||
boxShadow: `0 6px 16px ${alpha("#ef5350", 0.4)}`,
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
确认删除
|
|
||||||
</Button>
|
|
||||||
</DialogActions>
|
|
||||||
</Dialog>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,142 @@
|
|||||||
|
import type { ChatSessionRecord } from "./GlobalChatbox.types";
|
||||||
|
import {
|
||||||
|
createEmptyChatSession,
|
||||||
|
loadChatSessionById,
|
||||||
|
saveActiveChatState,
|
||||||
|
updateChatSessionTitle,
|
||||||
|
} from "./chatStorage";
|
||||||
|
|
||||||
|
type StoreName = "sessions" | "meta";
|
||||||
|
|
||||||
|
const stores: Record<StoreName, Map<string, any>> = {
|
||||||
|
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<number, []>;
|
||||||
|
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -51,6 +51,17 @@ const sanitizeMessages = (messages: Message[] | undefined) =>
|
|||||||
const sanitizeBranchGroups = (branchGroups: BranchGroup[] | undefined) =>
|
const sanitizeBranchGroups = (branchGroups: BranchGroup[] | undefined) =>
|
||||||
Array.isArray(branchGroups) ? cloneBranchGroups(branchGroups) : [];
|
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: {
|
const hasChatContent = (state: {
|
||||||
messages: Message[];
|
messages: Message[];
|
||||||
branchGroups: BranchGroup[];
|
branchGroups: BranchGroup[];
|
||||||
@@ -260,6 +271,9 @@ export const saveActiveChatState = async (
|
|||||||
const storageSessionId = state.storageSessionId ?? createId();
|
const storageSessionId = state.storageSessionId ?? createId();
|
||||||
const preferredTitle = state.title?.trim();
|
const preferredTitle = state.title?.trim();
|
||||||
const finalTitle = preferredTitle || existingSession?.title || "新对话";
|
const finalTitle = preferredTitle || existingSession?.title || "新对话";
|
||||||
|
const hasContentChanged =
|
||||||
|
!existingSession ||
|
||||||
|
serializeConversationState(existingSession) !== serializeConversationState(state);
|
||||||
const shouldAnchorCreatedAtToFirstMessage =
|
const shouldAnchorCreatedAtToFirstMessage =
|
||||||
Boolean(existingSession) && !hasChatContent(existingSession) && hasContent;
|
Boolean(existingSession) && !hasChatContent(existingSession) && hasContent;
|
||||||
const nextRecord: ChatSessionRecord = {
|
const nextRecord: ChatSessionRecord = {
|
||||||
@@ -269,7 +283,7 @@ export const saveActiveChatState = async (
|
|||||||
createdAt: shouldAnchorCreatedAtToFirstMessage
|
createdAt: shouldAnchorCreatedAtToFirstMessage
|
||||||
? now
|
? now
|
||||||
: existingSession?.createdAt ?? now,
|
: existingSession?.createdAt ?? now,
|
||||||
updatedAt: now,
|
updatedAt: hasContentChanged ? now : existingSession?.updatedAt ?? now,
|
||||||
sessionId: state.sessionId,
|
sessionId: state.sessionId,
|
||||||
messages: sanitizeMessages(state.messages),
|
messages: sanitizeMessages(state.messages),
|
||||||
branchGroups: sanitizeBranchGroups(state.branchGroups),
|
branchGroups: sanitizeBranchGroups(state.branchGroups),
|
||||||
@@ -315,7 +329,6 @@ export const updateChatSessionTitle = async (
|
|||||||
title: normalizedTitle,
|
title: normalizedTitle,
|
||||||
isTitleManuallyEdited:
|
isTitleManuallyEdited:
|
||||||
options?.isTitleManuallyEdited ?? session.isTitleManuallyEdited ?? false,
|
options?.isTitleManuallyEdited ?? session.isTitleManuallyEdited ?? false,
|
||||||
updatedAt: Date.now(),
|
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -48,6 +48,16 @@ type PromptRunOptions = {
|
|||||||
assistantMessage?: Message;
|
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 = (
|
const upsertProgress = (
|
||||||
progress: ChatProgress[] | undefined,
|
progress: ChatProgress[] | undefined,
|
||||||
event: StreamEvent & { type: "progress" },
|
event: StreamEvent & { type: "progress" },
|
||||||
@@ -158,6 +168,16 @@ 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({
|
||||||
|
storageSessionId: undefined,
|
||||||
|
title: undefined,
|
||||||
|
isTitleManuallyEdited: false,
|
||||||
|
messages: [],
|
||||||
|
sessionId: undefined,
|
||||||
|
branchGroups: [],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
sessionIdRef.current = sessionId;
|
sessionIdRef.current = sessionId;
|
||||||
@@ -180,6 +200,7 @@ export const useAgentChatSession = ({
|
|||||||
|
|
||||||
storageSessionIdRef.current = loadedState.storageSessionId;
|
storageSessionIdRef.current = loadedState.storageSessionId;
|
||||||
sessionIdRef.current = loadedState.sessionId;
|
sessionIdRef.current = loadedState.sessionId;
|
||||||
|
lastPersistedStateKeyRef.current = createPersistedStateKey(loadedState);
|
||||||
hydrationCompletedRef.current = true;
|
hydrationCompletedRef.current = true;
|
||||||
hydrationNonceRef.current += 1;
|
hydrationNonceRef.current += 1;
|
||||||
titleUpdateNonceRef.current += 1;
|
titleUpdateNonceRef.current += 1;
|
||||||
@@ -219,11 +240,19 @@ export const useAgentChatSession = ({
|
|||||||
sessionId,
|
sessionId,
|
||||||
branchGroups,
|
branchGroups,
|
||||||
};
|
};
|
||||||
|
const currentStateKey = createPersistedStateKey(state);
|
||||||
|
if (currentStateKey === lastPersistedStateKeyRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
void saveActiveChatState(state)
|
void saveActiveChatState(state)
|
||||||
.then((storageSessionId) => {
|
.then((storageSessionId) => {
|
||||||
if (hydrationNonceRef.current !== currentHydrationNonce) return;
|
if (hydrationNonceRef.current !== currentHydrationNonce) return;
|
||||||
storageSessionIdRef.current = storageSessionId;
|
storageSessionIdRef.current = storageSessionId;
|
||||||
|
lastPersistedStateKeyRef.current = createPersistedStateKey({
|
||||||
|
...state,
|
||||||
|
storageSessionId,
|
||||||
|
});
|
||||||
return listChatSessions();
|
return listChatSessions();
|
||||||
})
|
})
|
||||||
.then((sessions) => {
|
.then((sessions) => {
|
||||||
@@ -503,6 +532,14 @@ export const useAgentChatSession = ({
|
|||||||
setSessionId(undefined);
|
setSessionId(undefined);
|
||||||
sessionIdRef.current = undefined;
|
sessionIdRef.current = undefined;
|
||||||
storageSessionIdRef.current = undefined;
|
storageSessionIdRef.current = undefined;
|
||||||
|
lastPersistedStateKeyRef.current = createPersistedStateKey({
|
||||||
|
storageSessionId: undefined,
|
||||||
|
title: undefined,
|
||||||
|
isTitleManuallyEdited: false,
|
||||||
|
messages: [],
|
||||||
|
sessionId: undefined,
|
||||||
|
branchGroups: [],
|
||||||
|
});
|
||||||
titleUpdateNonceRef.current += 1;
|
titleUpdateNonceRef.current += 1;
|
||||||
setIsStreaming(false);
|
setIsStreaming(false);
|
||||||
}, []);
|
}, []);
|
||||||
@@ -521,6 +558,7 @@ export const useAgentChatSession = ({
|
|||||||
titleUpdateNonceRef.current += 1;
|
titleUpdateNonceRef.current += 1;
|
||||||
storageSessionIdRef.current = newState.storageSessionId;
|
storageSessionIdRef.current = newState.storageSessionId;
|
||||||
sessionIdRef.current = newState.sessionId;
|
sessionIdRef.current = newState.sessionId;
|
||||||
|
lastPersistedStateKeyRef.current = createPersistedStateKey(newState);
|
||||||
setMessages(newState.messages);
|
setMessages(newState.messages);
|
||||||
setSessionTitle(newState.title);
|
setSessionTitle(newState.title);
|
||||||
setIsSessionTitleManuallyEdited(newState.isTitleManuallyEdited ?? false);
|
setIsSessionTitleManuallyEdited(newState.isTitleManuallyEdited ?? false);
|
||||||
@@ -547,6 +585,7 @@ export const useAgentChatSession = ({
|
|||||||
titleUpdateNonceRef.current += 1;
|
titleUpdateNonceRef.current += 1;
|
||||||
storageSessionIdRef.current = nextState.storageSessionId;
|
storageSessionIdRef.current = nextState.storageSessionId;
|
||||||
sessionIdRef.current = nextState.sessionId;
|
sessionIdRef.current = nextState.sessionId;
|
||||||
|
lastPersistedStateKeyRef.current = createPersistedStateKey(nextState);
|
||||||
setBranchTransition(null);
|
setBranchTransition(null);
|
||||||
setMessages(nextState.messages);
|
setMessages(nextState.messages);
|
||||||
setSessionTitle(nextState.title);
|
setSessionTitle(nextState.title);
|
||||||
@@ -581,6 +620,14 @@ export const useAgentChatSession = ({
|
|||||||
titleUpdateNonceRef.current += 1;
|
titleUpdateNonceRef.current += 1;
|
||||||
storageSessionIdRef.current = undefined;
|
storageSessionIdRef.current = undefined;
|
||||||
sessionIdRef.current = undefined;
|
sessionIdRef.current = undefined;
|
||||||
|
lastPersistedStateKeyRef.current = createPersistedStateKey({
|
||||||
|
storageSessionId: undefined,
|
||||||
|
title: undefined,
|
||||||
|
isTitleManuallyEdited: false,
|
||||||
|
messages: [],
|
||||||
|
sessionId: undefined,
|
||||||
|
branchGroups: [],
|
||||||
|
});
|
||||||
setBranchTransition(null);
|
setBranchTransition(null);
|
||||||
setMessages([]);
|
setMessages([]);
|
||||||
setSessionTitle(undefined);
|
setSessionTitle(undefined);
|
||||||
@@ -599,6 +646,7 @@ export const useAgentChatSession = ({
|
|||||||
titleUpdateNonceRef.current += 1;
|
titleUpdateNonceRef.current += 1;
|
||||||
storageSessionIdRef.current = nextState.storageSessionId;
|
storageSessionIdRef.current = nextState.storageSessionId;
|
||||||
sessionIdRef.current = nextState.sessionId;
|
sessionIdRef.current = nextState.sessionId;
|
||||||
|
lastPersistedStateKeyRef.current = createPersistedStateKey(nextState);
|
||||||
setBranchTransition(null);
|
setBranchTransition(null);
|
||||||
setMessages(nextState.messages);
|
setMessages(nextState.messages);
|
||||||
setSessionTitle(nextState.title);
|
setSessionTitle(nextState.title);
|
||||||
@@ -637,12 +685,20 @@ export const useAgentChatSession = ({
|
|||||||
if (storageSessionIdRef.current === targetStorageSessionId) {
|
if (storageSessionIdRef.current === targetStorageSessionId) {
|
||||||
setSessionTitle(normalizedTitle);
|
setSessionTitle(normalizedTitle);
|
||||||
setIsSessionTitleManuallyEdited(true);
|
setIsSessionTitleManuallyEdited(true);
|
||||||
|
lastPersistedStateKeyRef.current = createPersistedStateKey({
|
||||||
|
storageSessionId: targetStorageSessionId,
|
||||||
|
title: normalizedTitle,
|
||||||
|
isTitleManuallyEdited: true,
|
||||||
|
messages,
|
||||||
|
sessionId: sessionIdRef.current,
|
||||||
|
branchGroups,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("[GlobalChatbox] Failed to rename chat session:", error);
|
console.error("[GlobalChatbox] Failed to rename chat session:", error);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[isHydrating],
|
[branchGroups, isHydrating, messages],
|
||||||
);
|
);
|
||||||
|
|
||||||
const regenerate = useCallback(async () => {
|
const regenerate = useCallback(async () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user