From 1c46dd4e75a620fc92bcf3f0e8eb8176509be110 Mon Sep 17 00:00:00 2001 From: 732642856 <732642856@qq.com> Date: Sat, 18 Jul 2026 04:48:27 +0800 Subject: [PATCH] feat: add chat history management actions --- frontend/src/app/globals.css | 13 ++- frontend/src/app/page.tsx | 101 +++++++++++++++---- tests/test_session_management_entrypoints.py | 19 ++++ 3 files changed, 109 insertions(+), 24 deletions(-) create mode 100644 tests/test_session_management_entrypoints.py diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css index 3d375d17..b3c2bf51 100644 --- a/frontend/src/app/globals.css +++ b/frontend/src/app/globals.css @@ -264,11 +264,14 @@ button:disabled { cursor: default; opacity: .45; } .new-chat { width: 100%; min-height: 44px; display: flex; align-items: center; justify-content: center; gap: 8px; padding: 0 14px; border: 0; background: var(--color-surface-dark); color: var(--color-on-dark); cursor: pointer; font-size: var(--type-body-sm); transition: background-color 140ms ease-out, transform 120ms ease-out; margin: var(--space-4) 0 var(--space-6); border-radius: var(--radius-md); font-weight: 500; } .sidebar-label { display: block; padding: 0 var(--space-3) var(--space-2); color: var(--color-ink-tertiary); font-size: var(--type-overline); font-weight: 500; letter-spacing: 1.5px; } .session-list { min-height: 0; overflow-y: auto; display: flex; flex-direction: column; gap: var(--space-1); } -.session-list button { position: relative; width: 100%; display: grid; gap: 2px; border: 0; background: transparent; cursor: pointer; text-align: left; transition: background-color 120ms ease-out, color 120ms ease-out, transform 120ms ease-out; min-height: 52px; padding: var(--space-2) var(--space-3) var(--space-2) var(--space-4); border-radius: var(--radius-md); color: var(--color-ink-secondary); } -.session-list button.is-active { color: var(--color-ink); background: var(--color-selected); } -.session-list button.is-active::before { position: absolute; border-radius: 3px; content: ""; top: var(--space-3); bottom: var(--space-3); left: var(--space-1); width: 2px; background: var(--color-action); } -.session-list button > span { overflow: hidden; line-height: 1.35; text-overflow: ellipsis; white-space: nowrap; font-size: 14px; font-weight: 500; } -.session-list button small { color: inherit; line-height: 1.3; opacity: .72; font-size: 12px; } +.session-row { position: relative; display: grid; gap: 4px; border-radius: var(--radius-md); color: var(--color-ink-secondary); } +.session-row > button { position: relative; width: 100%; display: grid; gap: 2px; border: 0; background: transparent; cursor: pointer; text-align: left; transition: background-color 120ms ease-out, color 120ms ease-out, transform 120ms ease-out; min-height: 52px; padding: var(--space-2) var(--space-3) var(--space-2) var(--space-4); border-radius: var(--radius-md); color: inherit; } +.session-row.is-active { color: var(--color-ink); background: var(--color-selected); } +.session-row.is-active::before { position: absolute; border-radius: 3px; content: ""; top: var(--space-3); bottom: var(--space-3); left: var(--space-1); width: 2px; background: var(--color-action); } +.session-row > button > span { overflow: hidden; line-height: 1.35; text-overflow: ellipsis; white-space: nowrap; font-size: 14px; font-weight: 500; } +.session-row > button small { color: inherit; line-height: 1.3; opacity: .72; font-size: 12px; } +.session-actions { display: flex; flex-wrap: wrap; gap: 4px; padding: 0 var(--space-2) var(--space-2) var(--space-4); } +.session-actions button { min-height: 26px; width: auto; padding: 0 7px; border: 1px solid var(--color-border); border-radius: 999px; background: var(--color-canvas); color: var(--color-ink-secondary); font-size: 11px; } .sidebar-footer { margin-top: 12px; padding-top: 10px; border-top: 1px solid var(--color-border); } .profile-trigger { width: 100%; display: grid; grid-template-columns: 34px minmax(0, 1fr) 18px; align-items: center; gap: 9px; padding: 5px 7px; border: 0; background: transparent; color: var(--color-ink); cursor: pointer; text-align: left; transition: background-color 120ms ease-out, transform 120ms ease-out; min-height: 56px; border-radius: var(--radius-md); } .profile-initial { width: 32px; height: 32px; display: grid; place-items: center; border-radius: 50%; font-size: 12px; text-transform: uppercase; border: 0; background: var(--color-action); color: var(--color-on-dark); font-weight: 500; } diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx index e8095d12..74ba4e51 100644 --- a/frontend/src/app/page.tsx +++ b/frontend/src/app/page.tsx @@ -667,6 +667,8 @@ export default function Home() { const [redeeming, setRedeeming] = useState(false); const [signingOut, setSigningOut] = useState(false); const [sessions, setSessions] = useState([]); + const [pinnedSessionIds, setPinnedSessionIds] = useState([]); + const [archivedSessionIds, setArchivedSessionIds] = useState([]); const [modelCatalog, setModelCatalog] = useState(null); const [activeSessionId, setActiveSessionId] = useState(""); const [draft, setDraft] = useState(""); @@ -710,16 +712,32 @@ export default function Home() { const uiPreviewMode = useRef(null); const activeSession = sessions.find((session) => session.id === activeSessionId) ?? sessions[0]; + const visibleSessions = sessions + .filter((session) => !archivedSessionIds.includes(session.id)) + .sort((left, right) => Number(pinnedSessionIds.includes(right.id)) - Number(pinnedSessionIds.includes(left.id))); const activeError = requestError && requestError.sessionId === activeSession?.id ? requestError.message : ""; const isLoading = pendingSessionId === activeSession?.id; const activeStreamingText = streamingReply && streamingReply.sessionId === activeSession?.id ? streamingReply.text : ""; + const accountId = account?.user.id; useEffect(() => { activeSessionIdRef.current = activeSessionId; }, [activeSessionId]); - const activeSuggestions = activeSession?.messages.reduce((latest, message) => message.role === "assistant" && message.suggestions?.length ? message.suggestions : latest, [] as string[]) ?? []; - const accountId = account?.user.id; + useEffect(() => { + if (!hydrated || !accountId) return; + const prefix = `jyotisha-session-controls:${accountId}:`; + setPinnedSessionIds(JSON.parse(localStorage.getItem(`${prefix}pinned`) || "[]")); + setArchivedSessionIds(JSON.parse(localStorage.getItem(`${prefix}archived`) || "[]")); + }, [accountId, hydrated]); + + useEffect(() => { + if (!hydrated || !accountId) return; + const prefix = `jyotisha-session-controls:${accountId}:`; + localStorage.setItem(`${prefix}pinned`, JSON.stringify(pinnedSessionIds)); + localStorage.setItem(`${prefix}archived`, JSON.stringify(archivedSessionIds)); + }, [accountId, archivedSessionIds, hydrated, pinnedSessionIds]); + const activeSuggestions = activeSession?.messages.reduce((latest, message) => message.role === "assistant" && message.suggestions?.length ? message.suggestions : latest, [] as string[]) ?? []; useEffect(() => { if (!accountId) { setChartLibrary([]); @@ -1121,6 +1139,45 @@ export default function Home() { if (insertError) throw new Error(`云端同步失败:${insertError.message}`); } + async function renameSession(session: ChatSession) { + const title = window.prompt("重命名聊天记录", session.title)?.trim(); + if (!title || title === session.title) return; + const nextSession = { ...session, title, updatedAt: timestamp() }; + updateSession(session.id, () => nextSession); + try { + await persistSession(nextSession); + } catch (caught) { + setComposerNotice(caught instanceof Error ? caught.message : "重命名同步失败"); + } + } + + async function deleteSession(session: ChatSession) { + if (!account || !window.confirm(`删除“${session.title}”?此操作不可恢复。`)) return; + const previousSessions = sessions; + const nextSessions = sessions.filter((item) => item.id !== session.id); + setSessions(nextSessions); + setPinnedSessionIds((current) => current.filter((id) => id !== session.id)); + setArchivedSessionIds((current) => current.filter((id) => id !== session.id)); + if (activeSessionId === session.id) setActiveSessionId(nextSessions[0]?.id ?? ""); + try { + const supabase = createBrowserSupabaseClient(); + const { error } = await supabase.from("chat_sessions").delete().eq("id", session.id).eq("user_id", account.user.id); + if (error) throw error; + } catch (caught) { + setSessions(previousSessions); + setComposerNotice(caught instanceof Error ? `删除失败:${caught.message}` : "删除失败"); + } + } + + function togglePinnedSession(sessionId: string) { + setPinnedSessionIds((current) => current.includes(sessionId) ? current.filter((id) => id !== sessionId) : [sessionId, ...current]); + } + + function toggleArchivedSession(sessionId: string) { + setArchivedSessionIds((current) => current.includes(sessionId) ? current.filter((id) => id !== sessionId) : [sessionId, ...current]); + if (activeSessionId === sessionId) setActiveSessionId(visibleSessions.find((session) => session.id !== sessionId)?.id ?? ""); + } + async function startNewChat() { if (!account || !modelCatalog || creatingSession) return; setMobileSidebarOpen(false); @@ -1934,23 +1991,29 @@ export default function Home() { diff --git a/tests/test_session_management_entrypoints.py b/tests/test_session_management_entrypoints.py new file mode 100644 index 00000000..580aa8ca --- /dev/null +++ b/tests/test_session_management_entrypoints.py @@ -0,0 +1,19 @@ +from pathlib import Path + + +PAGE = Path("frontend/src/app/page.tsx") + + +def test_chat_history_management_actions_are_exposed() -> None: + source = PAGE.read_text(encoding="utf-8") + for expected in ( + "renameSession", + "deleteSession", + "togglePinnedSession", + "toggleArchivedSession", + "置顶", + "重命名", + "归档", + "删除", + ): + assert expected in source