fix profile-aware session history actions (#1011)

This commit is contained in:
ekko
2026-05-25 12:32:42 +08:00
committed by GitHub
parent bbb8b1d536
commit 56c6cf3e2d
7 changed files with 211 additions and 58 deletions
+1
View File
@@ -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'
+15 -2
View File
@@ -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<string | BatchDeleteSessionTarget>): 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
@@ -43,7 +43,7 @@ const currentMode = ref<"chat" | "live">("chat");
// Batch selection mode
const isBatchMode = ref(false);
const selectedSessionIds = ref<Set<string>>(new Set());
const selectedSessionKeys = ref<Set<string>>(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<Session, "id" | "profile">): 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)"
/>
</template>
@@ -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)"
/>
</div>
</aside>
@@ -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<SessionSummary[]>([])
const hermesSessionsLoading = ref(false)
@@ -40,17 +42,22 @@ const hermesSessionsLoaded = ref(false)
const historySessionId = ref<string | null>(null)
const historySession = ref<Session | null>(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<Set<string>>(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 })
@@ -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' })
}
}
+29
View File
@@ -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<string, unknown>) {
@@ -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' },
],
})
})
})
})
+36
View File
@@ -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' }] }