From 56c6cf3e2db8e99e92a801b72d6ae5a9db5e7ff8 Mon Sep 17 00:00:00 2001 From: ekko <152005280+EKKOLearnAI@users.noreply.github.com> Date: Mon, 25 May 2026 12:32:42 +0800 Subject: [PATCH] fix profile-aware session history actions (#1011) --- packages/client/src/api/client.ts | 1 + packages/client/src/api/hermes/sessions.ts | 17 ++++- .../src/components/hermes/chat/ChatPanel.vue | 58 +++++++++------- .../client/src/views/hermes/HistoryView.vue | 69 +++++++++++++------ .../server/src/controllers/hermes/sessions.ts | 59 +++++++++++++--- tests/client/api.test.ts | 29 ++++++++ tests/server/sessions-controller.test.ts | 36 ++++++++++ 7 files changed, 211 insertions(+), 58 deletions(-) diff --git a/packages/client/src/api/client.ts b/packages/client/src/api/client.ts index ed5c695..80bfe09 100644 --- a/packages/client/src/api/client.ts +++ b/packages/client/src/api/client.ts @@ -75,6 +75,7 @@ function shouldAttachProfileHeader(path: string, options: RequestInit): boolean function isProfileWideSessionCollection(pathname: string): boolean { return pathname === '/api/hermes/sessions' || + pathname === '/api/hermes/sessions/batch-delete' || pathname === '/api/hermes/search/sessions' || pathname === '/api/hermes/sessions/search' || pathname === '/api/hermes/sessions/conversations' diff --git a/packages/client/src/api/hermes/sessions.ts b/packages/client/src/api/hermes/sessions.ts index e5b196b..9972594 100644 --- a/packages/client/src/api/hermes/sessions.ts +++ b/packages/client/src/api/hermes/sessions.ts @@ -122,13 +122,26 @@ export async function deleteSession(id: string, profile?: string | null): Promis } } -export async function batchDeleteSessions(ids: string[]): Promise<{ deleted: number; failed: number; errors: Array<{ id: string; error: string }> }> { +export interface BatchDeleteSessionTarget { + id: string + profile?: string | null +} + +export async function batchDeleteSessions(targets: Array): Promise<{ deleted: number; failed: number; errors: Array<{ id: string; error: string }> }> { try { + const sessions = targets.map(target => + typeof target === 'string' + ? { id: target } + : { id: target.id, profile: target.profile || undefined }, + ) const res = await request<{ deleted: number; failed: number; errors: Array<{ id: string; error: string }> }>( '/api/hermes/sessions/batch-delete', { method: 'POST', - body: JSON.stringify({ ids }), + body: JSON.stringify({ + ids: sessions.map(session => session.id), + sessions, + }), } ) return res diff --git a/packages/client/src/components/hermes/chat/ChatPanel.vue b/packages/client/src/components/hermes/chat/ChatPanel.vue index b1afa2b..4ce9aae 100644 --- a/packages/client/src/components/hermes/chat/ChatPanel.vue +++ b/packages/client/src/components/hermes/chat/ChatPanel.vue @@ -43,7 +43,7 @@ const currentMode = ref<"chat" | "live">("chat"); // Batch selection mode const isBatchMode = ref(false); -const selectedSessionIds = ref>(new Set()); +const selectedSessionKeys = ref>(new Set()); const showBatchDeleteConfirm = ref(false); const isBatchDeleting = ref(false); @@ -328,39 +328,49 @@ function toggleBatchMode() { if (isBatchDeleting.value) return; isBatchMode.value = !isBatchMode.value; if (!isBatchMode.value) { - selectedSessionIds.value.clear(); + selectedSessionKeys.value.clear(); showBatchDeleteConfirm.value = false; } } -function toggleSessionSelection(id: string) { +function sessionSelectionKey(session: Pick): string { + return `${session.profile || "default"}\u0000${session.id}`; +} + +function toggleSessionSelection(session: Session) { if (isBatchDeleting.value) return; - if (selectedSessionIds.value.has(id)) { - selectedSessionIds.value.delete(id); + const key = sessionSelectionKey(session); + if (selectedSessionKeys.value.has(key)) { + selectedSessionKeys.value.delete(key); } else { - selectedSessionIds.value.add(id); + selectedSessionKeys.value.add(key); } - selectedSessionIds.value = new Set(selectedSessionIds.value); - if (selectedSessionIds.value.size === 0) { + selectedSessionKeys.value = new Set(selectedSessionKeys.value); + if (selectedSessionKeys.value.size === 0) { showBatchDeleteConfirm.value = false; } } -function isSessionSelected(id: string): boolean { - return selectedSessionIds.value.has(id); +function isSessionSelected(session: Session): boolean { + return selectedSessionKeys.value.has(sessionSelectionKey(session)); } async function handleBatchDelete() { - if (selectedSessionIds.value.size === 0 || isBatchDeleting.value) return; + if (selectedSessionKeys.value.size === 0 || isBatchDeleting.value) return; - const ids = Array.from(selectedSessionIds.value); + const sessionsByKey = new Map(chatStore.sessions.map((session) => [sessionSelectionKey(session), session])); + const targets = Array.from(selectedSessionKeys.value) + .map((key) => sessionsByKey.get(key)) + .filter((session): session is Session => Boolean(session)) + .map((session) => ({ id: session.id, profile: session.profile || null })); + if (targets.length === 0) return; isBatchDeleting.value = true; try { - const result = await batchDeleteSessions(ids); + const result = await batchDeleteSessions(targets); if (result.deleted > 0) { // Remove from pinned sessions - for (const id of ids) { - sessionBrowserPrefsStore.removePinned(id); + for (const target of targets) { + sessionBrowserPrefsStore.removePinned(target.id); } // Remove deleted sessions from local store (without calling API again) @@ -380,7 +390,7 @@ async function handleBatchDelete() { isBatchDeleting.value = false; showBatchDeleteConfirm.value = false; isBatchMode.value = false; - selectedSessionIds.value.clear(); + selectedSessionKeys.value.clear(); } } @@ -391,16 +401,16 @@ function handleBatchDeleteConfirm() { function selectAllSessions() { if (isBatchDeleting.value) return; - selectedSessionIds.value.clear(); + selectedSessionKeys.value.clear(); for (const session of chatStore.sessions) { if (session.id !== chatStore.activeSessionId) { - selectedSessionIds.value.add(session.id); + selectedSessionKeys.value.add(sessionSelectionKey(session)); } } - selectedSessionIds.value = new Set(selectedSessionIds.value); + selectedSessionKeys.value = new Set(selectedSessionKeys.value); } -const selectedCount = computed(() => selectedSessionIds.value.size); +const selectedCount = computed(() => selectedSessionKeys.value.size); const canSelectAll = computed(() => { return chatStore.sessions.some(s => s.id !== chatStore.activeSessionId); }); @@ -856,13 +866,13 @@ async function handleSessionModelCustomSubmit() { " :streaming="chatStore.isSessionLive(s.id)" :selectable="isBatchMode" - :selected="isSessionSelected(s.id)" + :selected="isSessionSelected(s)" :show-profile="true" :to="sessionHref(s.id)" @select="handleSessionClick(s.id)" @contextmenu="handleContextMenu($event, s.id)" @delete="handleDeleteSession(s.id)" - @toggle-select="toggleSessionSelection(s.id)" + @toggle-select="toggleSessionSelection(s)" /> @@ -878,13 +888,13 @@ async function handleSessionModelCustomSubmit() { " :streaming="chatStore.isSessionLive(s.id)" :selectable="isBatchMode" - :selected="isSessionSelected(s.id)" + :selected="isSessionSelected(s)" :show-profile="true" :to="sessionHref(s.id)" @select="handleSessionClick(s.id)" @contextmenu="handleContextMenu($event, s.id)" @delete="handleDeleteSession(s.id)" - @toggle-select="toggleSessionSelection(s.id)" + @toggle-select="toggleSessionSelection(s)" /> diff --git a/packages/client/src/views/hermes/HistoryView.vue b/packages/client/src/views/hermes/HistoryView.vue index 9cf91ee..e72957b 100644 --- a/packages/client/src/views/hermes/HistoryView.vue +++ b/packages/client/src/views/hermes/HistoryView.vue @@ -32,6 +32,8 @@ const routeProfile = computed(() => { return typeof value === 'string' && value.trim() ? value : null }) +const effectiveHistoryProfile = computed(() => profilesStore.activeProfileName || routeProfile.value || null) + // Hermes history sessions (exclude api_server) const hermesSessions = ref([]) const hermesSessionsLoading = ref(false) @@ -40,17 +42,22 @@ const hermesSessionsLoaded = ref(false) const historySessionId = ref(null) const historySession = ref(null) const showOutline = ref(false) +let hermesSessionsRequestId = 0 async function loadHermesSessions() { - if (hermesSessionsLoading.value) return + const requestId = ++hermesSessionsRequestId hermesSessionsLoading.value = true try { - hermesSessions.value = await fetchHermesSessions(undefined, undefined, routeProfile.value) + const sessions = await fetchHermesSessions(undefined, undefined, effectiveHistoryProfile.value) + if (requestId !== hermesSessionsRequestId) return + hermesSessions.value = sessions hermesSessionsLoaded.value = true } catch (err) { console.error('Failed to load Hermes sessions:', err) } finally { - hermesSessionsLoading.value = false + if (requestId === hermesSessionsRequestId) { + hermesSessionsLoading.value = false + } } } @@ -132,6 +139,29 @@ async function handleSessionClick(sessionId: string, profile?: string | null) { }) } +async function openDefaultHistorySession(replace = false) { + const firstCliSession = hermesSessions.value.find(s => s.source === 'cli') + const firstSession = firstCliSession || hermesSessions.value[0] + if (!firstSession) { + historySessionId.value = null + historySession.value = null + if (routeSessionId.value) await router.replace({ name: 'hermes.history' }) + return + } + + if (collapsedGroups.value.has(firstSession.source)) { + collapsedGroups.value = new Set([...collapsedGroups.value].filter(source => source !== firstSession.source)) + } + + const location = { + name: 'hermes.historySession', + params: { sessionId: firstSession.id }, + query: firstSession.profile ? { profile: firstSession.profile } : undefined, + } + if (replace) await router.replace(location) + else await router.push(location) +} + async function syncRouteSession() { const sessionId = routeSessionId.value if (!sessionId) return @@ -143,8 +173,10 @@ async function syncRouteSession() { return } - if (historySessionId.value !== sessionId) { - await loadHistorySession(sessionId, routeProfile.value) + const sessionProfile = routeProfile.value || findHistorySession(sessionId)?.profile || null + const currentProfile = historySession.value?.profile || null + if (historySessionId.value !== sessionId || currentProfile !== sessionProfile) { + await loadHistorySession(sessionId, sessionProfile) } } @@ -174,7 +206,6 @@ watch([routeSessionId, routeProfile], async ([sessionId]) => { if (!sessionId) { historySessionId.value = null historySession.value = null - if (hermesSessionsLoaded.value) await loadHermesSessions() return } if (!hermesSessionsLoaded.value) return @@ -184,6 +215,15 @@ watch([routeSessionId, routeProfile], async ([sessionId]) => { await syncRouteSession() }) +watch(() => profilesStore.activeProfileName, async () => { + if (!hermesSessionsLoaded.value) return + if (profilesStore.switching) return + historySessionId.value = null + historySession.value = null + await loadHermesSessions() + await openDefaultHistorySession(true) +}) + const collapsedGroups = ref>(new Set(JSON.parse(localStorage.getItem('hermes_collapsed_groups') || '[]'))) // Convert SessionSummary to Session format @@ -292,23 +332,10 @@ watch(groupedSessions, groups => { } }, { once: true }) -// Auto-load first CLI session when Hermes sessions are loaded +// Auto-load the first CLI session when Hermes sessions are loaded. watch(hermesSessionsLoaded, (loaded) => { if (loaded && hermesSessions.value.length > 0 && !routeSessionId.value) { - // Only auto-load if no session is currently active - if (!historySessionId.value || !hermesSessions.value.find(s => s.id === historySessionId.value)) { - // Find first CLI session. - const firstCliSession = hermesSessions.value.find(s => s.source === 'cli') - if (firstCliSession) { - // Ensure the CLI group is expanded - if (collapsedGroups.value.has(firstCliSession.source)) { - collapsedGroups.value = new Set([...collapsedGroups.value].filter(s => s !== firstCliSession.source)) - } - // Load session details - void handleSessionClick(firstCliSession.id, firstCliSession.profile) - } - // If no CLI session exists, don't auto-load any session - } + void openDefaultHistorySession(false) } }, { once: true }) diff --git a/packages/server/src/controllers/hermes/sessions.ts b/packages/server/src/controllers/hermes/sessions.ts index 6d62dfb..3d7c4a7 100644 --- a/packages/server/src/controllers/hermes/sessions.ts +++ b/packages/server/src/controllers/hermes/sessions.ts @@ -74,6 +74,11 @@ interface HermesDeleteResult { error?: string } +interface BatchDeleteTarget { + id: string + profile?: string | null +} + function hasProfileOnDisk(profile: string): boolean { return listProfileNamesFromDisk().includes(profile || 'default') } @@ -292,15 +297,31 @@ export async function remove(ctx: any) { } export async function batchRemove(ctx: any) { - const { ids } = ctx.request.body as { ids?: string[] } - if (!ids || !Array.isArray(ids) || ids.length === 0) { + const { ids, sessions } = ctx.request.body as { ids?: string[]; sessions?: BatchDeleteTarget[] } + const rawTargets = Array.isArray(sessions) && sessions.length > 0 ? sessions : ids + if (!rawTargets || !Array.isArray(rawTargets) || rawTargets.length === 0) { ctx.status = 400 ctx.body = { error: 'ids is required and must be a non-empty array' } return } - const validIds = ids.filter(id => typeof id === 'string' && id.trim() !== '') - if (validIds.length === 0) { + const targets = rawTargets + .map((target): BatchDeleteTarget | null => { + if (typeof target === 'string') { + const id = target.trim() + return id ? { id } : null + } + if (!target || typeof target.id !== 'string') return null + const id = target.id.trim() + if (!id) return null + const profile = typeof target.profile === 'string' && target.profile.trim() + ? target.profile.trim() + : undefined + return { id, profile } + }) + .filter((target): target is BatchDeleteTarget => Boolean(target)) + + if (targets.length === 0) { ctx.status = 400 ctx.body = { error: 'No valid session ids provided' } return @@ -315,14 +336,22 @@ export async function batchRemove(ctx: any) { hermesErrors: [] as Array<{ id: string; profile?: string; error: string }> } - for (const id of validIds) { + for (const target of targets) { + const { id } = target const existing = localGetSession(id) - if (existing && !canAccessProfile(ctx, existing.profile)) { + const targetProfile = target.profile || existing?.profile + if (targetProfile && !canAccessProfile(ctx, targetProfile)) { + results.failed++ + results.errors.push({ id, error: `Profile "${targetProfile || 'default'}" is not available for this user` }) + continue + } + if (!targetProfile && existing && !canAccessProfile(ctx, existing.profile)) { results.failed++ results.errors.push({ id, error: `Profile "${existing.profile || 'default'}" is not available for this user` }) continue } - const hermes = await deleteHermesSessionIfPresent(id, existing?.profile) + + const hermes = await deleteHermesSessionIfPresent(id, targetProfile) if (hermes.deleted) { results.hermesDeleted++ } else if (hermes.attempted && hermes.error) { @@ -330,13 +359,21 @@ export async function batchRemove(ctx: any) { results.hermesErrors.push({ id, profile: hermes.profile, error: hermes.error }) } - const ok = localDeleteSession(id) - if (ok) { - deleteUsage(id) + const shouldDeleteLocal = Boolean(existing && (!targetProfile || existing.profile === targetProfile)) + if (shouldDeleteLocal) { + const ok = localDeleteSession(id) + if (ok) { + deleteUsage(id) + results.deleted++ + } else { + results.failed++ + results.errors.push({ id, error: 'Failed to delete session' }) + } + } else if (hermes.deleted) { results.deleted++ } else { results.failed++ - results.errors.push({ id, error: 'Failed to delete session' }) + results.errors.push({ id, error: 'Session not found' }) } } diff --git a/tests/client/api.test.ts b/tests/client/api.test.ts index cbc92c5..e62c221 100644 --- a/tests/client/api.test.ts +++ b/tests/client/api.test.ts @@ -15,6 +15,7 @@ vi.mock('@/router', () => ({ import { getApiKey, setApiKey, clearApiKey, hasApiKey, getStoredUserRole, isStoredSuperAdmin, request } from '../../packages/client/src/api/client' import { getDownloadUrl } from '../../packages/client/src/api/hermes/download' import { uploadFiles } from '../../packages/client/src/api/hermes/files' +import { batchDeleteSessions } from '../../packages/client/src/api/hermes/sessions' import router from '@/router' function fakeJwt(payload: Record) { @@ -202,4 +203,32 @@ describe('API Client', () => { expect(options.body).toBeInstanceOf(FormData) }) }) + + describe('sessions API', () => { + it('sends profile-qualified targets for batch deletes', async () => { + localStorage.setItem('hermes_active_profile_name', 'research') + mockFetch.mockResolvedValue({ + ok: true, + status: 200, + json: () => Promise.resolve({ deleted: 2, failed: 0, errors: [] }), + }) + + await batchDeleteSessions([ + { id: 'session-default', profile: 'default' }, + { id: 'session-travel', profile: 'travel' }, + ]) + + const [url, options] = mockFetch.mock.calls[0] + expect(url).toBe('/api/hermes/sessions/batch-delete') + expect(options.method).toBe('POST') + expect(options.headers['X-Hermes-Profile']).toBeUndefined() + expect(JSON.parse(options.body)).toEqual({ + ids: ['session-default', 'session-travel'], + sessions: [ + { id: 'session-default', profile: 'default' }, + { id: 'session-travel', profile: 'travel' }, + ], + }) + }) + }) }) diff --git a/tests/server/sessions-controller.test.ts b/tests/server/sessions-controller.test.ts index bd493c8..31a90b2 100644 --- a/tests/server/sessions-controller.test.ts +++ b/tests/server/sessions-controller.test.ts @@ -601,6 +601,42 @@ describe('session conversations controller', () => { }) }) + it('batch deletes sessions from their requested profiles', async () => { + listUserProfilesMock.mockReturnValue([{ profile_name: 'default' }, { profile_name: 'travel' }]) + getSessionMock.mockImplementation((id: string) => ({ + id, + profile: id === 'travel-session' ? 'travel' : 'default', + })) + getExactSessionDetailFromDbWithProfileMock.mockResolvedValue({ id: 'matched', messages: [] }) + deleteHermesSessionForProfileMock.mockResolvedValue(true) + localDeleteSessionMock.mockReturnValue(true) + + const mod = await import('../../packages/server/src/controllers/hermes/sessions') + const ctx: any = { + request: { + body: { + sessions: [ + { id: 'default-session', profile: 'default' }, + { id: 'travel-session', profile: 'travel' }, + ], + }, + }, + state: { + user: { id: 1, role: 'admin' }, + }, + body: null, + } + await mod.batchRemove(ctx) + + expect(getExactSessionDetailFromDbWithProfileMock).toHaveBeenCalledWith('default-session', 'default') + expect(getExactSessionDetailFromDbWithProfileMock).toHaveBeenCalledWith('travel-session', 'travel') + expect(deleteHermesSessionForProfileMock).toHaveBeenCalledWith('default-session', 'default') + expect(deleteHermesSessionForProfileMock).toHaveBeenCalledWith('travel-session', 'travel') + expect(localDeleteSessionMock).toHaveBeenCalledWith('default-session') + expect(localDeleteSessionMock).toHaveBeenCalledWith('travel-session') + expect(ctx.body).toMatchObject({ ok: true, deleted: 2, failed: 0, hermesDeleted: 2 }) + }) + describe('exportSession', () => { it('returns session as JSON download with correct headers (full mode)', async () => { const sessionData = { id: 'abc-123', title: 'Test Session', messages: [{ id: 1, role: 'user', content: 'hello' }] }