fix profile-aware session history actions (#1011)
This commit is contained in:
@@ -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'
|
||||
|
||||
@@ -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' })
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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' },
|
||||
],
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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' }] }
|
||||
|
||||
Reference in New Issue
Block a user