mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-21 16:18:55 +00:00
perf(desktop): batch sidebar session slices into one profile-DB pass
The sidebar refresh fired three /api/profiles/sessions calls (recents, cron, messaging), and each one reopened every selected profile's state.db and re-ran list_sessions_rich + session_count — ~3N DB opens/counts per refresh, on every turn/broadcast/reconnect. Add GET /api/profiles/sessions/sidebar: one pass that opens each profile DB once and runs the three source-scoped queries together (recents scoped to the active profile; cron + messaging cross-profile), returning the three windows in one payload. Same read-only projection, 300s active heuristic, and caller- supplied source taxonomy (recents_exclude / messaging_exclude / source=cron) as the per-slice endpoint. Renderer refreshSessions now makes one listSidebarSessions call and distributes recents/cron/messaging to their stores (cron *jobs* stay a separate getCronJobs API). Electron splices remote profiles per slice via fetchProfilesSessionSlice (reusing the proven per-slice merge) so remote correctness is preserved; the no-remote common case gets the single-open fast path. From the Desktop performance audit (P1: "Batch sidebar session slices").
This commit is contained in:
parent
614dc194ea
commit
40160e2a04
7 changed files with 476 additions and 48 deletions
|
|
@ -8089,6 +8089,71 @@ async function interceptSessionRequestForRemote(request) {
|
|||
return mergeRemoteProfileSessions(searchParams, remoteProfiles)
|
||||
}
|
||||
|
||||
// Batched sidebar slices. With no remote profiles the local batched endpoint
|
||||
// (one DB open per profile) serves it directly — take the fast path. When
|
||||
// remotes exist, fan the three slices back out to the per-slice
|
||||
// /api/profiles/sessions path (which already merges remote rows correctly) and
|
||||
// reassemble; local profiles fall back to three primary reads there, but
|
||||
// remote correctness is preserved.
|
||||
if (method === 'GET' && pathname === '/api/profiles/sessions/sidebar') {
|
||||
const remoteProfiles = configuredRemoteProfileNames()
|
||||
|
||||
if (remoteProfiles.length === 0) {
|
||||
return undefined // local fast path → batched endpoint's single DB open
|
||||
}
|
||||
|
||||
const recentsProfile = (searchParams.get('recents_profile') || 'all').trim() || 'all'
|
||||
|
||||
const sliceParams = (limitKey, defaultLimit, extra) => {
|
||||
const sp = new URLSearchParams({
|
||||
limit: searchParams.get(limitKey) || defaultLimit,
|
||||
offset: '0',
|
||||
min_messages: '1',
|
||||
archived: 'exclude',
|
||||
order: 'recent',
|
||||
...extra
|
||||
})
|
||||
|
||||
return sp
|
||||
}
|
||||
|
||||
const recentsSp = sliceParams('recents_limit', '20', { profile: recentsProfile })
|
||||
const recentsExclude = searchParams.get('recents_exclude')
|
||||
|
||||
if (recentsExclude) {
|
||||
recentsSp.set('exclude_sources', recentsExclude)
|
||||
}
|
||||
|
||||
const cronSp = sliceParams('cron_limit', '50', { profile: 'all', source: 'cron' })
|
||||
|
||||
const messagingSp = sliceParams('messaging_limit', '100', { profile: 'all' })
|
||||
const messagingExclude = searchParams.get('messaging_exclude')
|
||||
|
||||
if (messagingExclude) {
|
||||
messagingSp.set('exclude_sources', messagingExclude)
|
||||
}
|
||||
|
||||
const [recents, cron, messaging] = await Promise.all([
|
||||
fetchProfilesSessionSlice(recentsSp, remoteProfiles),
|
||||
fetchProfilesSessionSlice(cronSp, remoteProfiles),
|
||||
fetchProfilesSessionSlice(messagingSp, remoteProfiles)
|
||||
])
|
||||
|
||||
return {
|
||||
recents: {
|
||||
sessions: rowsOf(recents),
|
||||
total: Number(recents?.total) || 0,
|
||||
profile_totals: recents?.profile_totals || {}
|
||||
},
|
||||
cron: { sessions: rowsOf(cron) },
|
||||
messaging: {
|
||||
sessions: rowsOf(messaging),
|
||||
total: Number(messaging?.total) || rowsOf(messaging).length
|
||||
},
|
||||
errors: []
|
||||
}
|
||||
}
|
||||
|
||||
// Per-session read/mutation. Owner is in ?profile= (reads) or request.profile
|
||||
// (mutations). Two remote shapes:
|
||||
// - per-profile override: route to that profile's own remote, sans profile
|
||||
|
|
@ -8153,6 +8218,30 @@ async function remoteSessionList(profile, searchParams) {
|
|||
return { ...(data as any), sessions: rowsOf(data) }
|
||||
}
|
||||
|
||||
// Resolve one /api/profiles/sessions slice with remote profiles spliced in —
|
||||
// the same branch logic as the GET /api/profiles/sessions intercept, but always
|
||||
// returns data (never `undefined`) so a batched caller can compose slices. A
|
||||
// specific local profile reads from the local primary; a remote-override profile
|
||||
// reads from its remote; 'all' merges every remote into the primary aggregate.
|
||||
async function fetchProfilesSessionSlice(searchParams, remoteProfiles) {
|
||||
const requested = (searchParams.get('profile') || 'all').trim() || 'all'
|
||||
|
||||
if (requested !== 'all') {
|
||||
if (profileHasRemoteOverride(requested)) {
|
||||
return remoteSessionList(requested, searchParams)
|
||||
}
|
||||
|
||||
const primary = await ensureBackend(null)
|
||||
|
||||
return fetchJson(`${primary.baseUrl}/api/profiles/sessions?${searchParams}`, primary.token, {
|
||||
method: 'GET',
|
||||
timeoutMs: DEFAULT_FETCH_TIMEOUT_MS
|
||||
}).catch(() => ({ sessions: [], total: 0, profile_totals: {} }))
|
||||
}
|
||||
|
||||
return mergeRemoteProfileSessions(searchParams, remoteProfiles)
|
||||
}
|
||||
|
||||
// Unified list: primary's local aggregate, with each remote profile's stale local
|
||||
// rows/totals swapped for the remote's real ones, re-sorted by recency and
|
||||
// re-windowed to the requested page. A dead remote contributes nothing rather
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
import { act, renderHook } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { SessionInfo } from '@/hermes'
|
||||
import { $sessions, $sessionsLoading, setSessions, setSessionsLoading } from '@/store/session'
|
||||
import type { SessionInfo, SidebarSessionsResponse } from '@/hermes'
|
||||
import { $cronSessions, $messagingSessions, $sessions, $sessionsLoading, setCronSessions, setMessagingSessions, setSessions, setSessionsLoading } from '@/store/session'
|
||||
|
||||
import { useSessionListActions } from './use-session-list-actions'
|
||||
|
||||
|
|
@ -29,29 +29,50 @@ const row = (id: string, over: Partial<SessionInfo> = {}): SessionInfo =>
|
|||
...over
|
||||
}) as SessionInfo
|
||||
|
||||
// Batched sidebar response builder. `refreshSessions` now makes ONE
|
||||
// listSidebarSessions call that returns all three slices, replacing the three
|
||||
// separate listAllProfileSessions calls (each of which reopened every profile
|
||||
// DB) — #66377-adjacent perf work from the desktop audit canvas.
|
||||
const sidebar = (
|
||||
recents: { sessions: SessionInfo[]; total?: number; profile_totals?: Record<string, number> },
|
||||
cron: SessionInfo[] = [],
|
||||
messaging: SessionInfo[] = []
|
||||
): SidebarSessionsResponse => ({
|
||||
recents: { sessions: recents.sessions, total: recents.total, profile_totals: recents.profile_totals },
|
||||
cron: { sessions: cron },
|
||||
messaging: { sessions: messaging, total: messaging.length }
|
||||
})
|
||||
|
||||
const listSidebarSessions = vi.fn()
|
||||
const listAllProfileSessions = vi.fn()
|
||||
|
||||
vi.mock('@/hermes', async importOriginal => ({
|
||||
...(await importOriginal<Record<string, unknown>>()),
|
||||
getCronJobs: vi.fn(async () => []),
|
||||
listAllProfileSessions: (...args: unknown[]) => listAllProfileSessions(...args)
|
||||
listAllProfileSessions: (...args: unknown[]) => listAllProfileSessions(...args),
|
||||
listSidebarSessions: (...args: unknown[]) => listSidebarSessions(...args)
|
||||
}))
|
||||
|
||||
beforeEach(() => {
|
||||
listSidebarSessions.mockReset()
|
||||
listAllProfileSessions.mockReset()
|
||||
setSessions([])
|
||||
setCronSessions([])
|
||||
setMessagingSessions([])
|
||||
setSessionsLoading(false)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
setSessions([])
|
||||
setCronSessions([])
|
||||
setMessagingSessions([])
|
||||
setSessionsLoading(false)
|
||||
})
|
||||
|
||||
describe('refreshSessions identity + loading hygiene', () => {
|
||||
it('keeps the previous $sessions array when the refresh is content-identical', async () => {
|
||||
const rows = [row('a'), row('b')]
|
||||
listAllProfileSessions.mockResolvedValue({ sessions: rows, total: 2, profile_totals: { default: 2 } })
|
||||
listSidebarSessions.mockResolvedValue(sidebar({ sessions: rows, total: 2, profile_totals: { default: 2 } }))
|
||||
|
||||
const { result } = renderHook(() => useSessionListActions({ profileScope: 'default' }))
|
||||
|
||||
|
|
@ -63,11 +84,9 @@ describe('refreshSessions identity + loading hygiene', () => {
|
|||
expect(first.map(s => s.id)).toEqual(['a', 'b'])
|
||||
|
||||
// Second refresh returns fresh (but equal) row objects, as the API does.
|
||||
listAllProfileSessions.mockResolvedValue({
|
||||
sessions: [row('a'), row('b')],
|
||||
total: 2,
|
||||
profile_totals: { default: 2 }
|
||||
})
|
||||
listSidebarSessions.mockResolvedValue(
|
||||
sidebar({ sessions: [row('a'), row('b')], total: 2, profile_totals: { default: 2 } })
|
||||
)
|
||||
|
||||
await act(async () => {
|
||||
await result.current.refreshSessions()
|
||||
|
|
@ -77,7 +96,7 @@ describe('refreshSessions identity + loading hygiene', () => {
|
|||
})
|
||||
|
||||
it('swaps the array when rows actually changed', async () => {
|
||||
listAllProfileSessions.mockResolvedValue({ sessions: [row('a')], total: 1, profile_totals: {} })
|
||||
listSidebarSessions.mockResolvedValue(sidebar({ sessions: [row('a')], total: 1, profile_totals: {} }))
|
||||
const { result } = renderHook(() => useSessionListActions({ profileScope: 'default' }))
|
||||
|
||||
await act(async () => {
|
||||
|
|
@ -86,11 +105,9 @@ describe('refreshSessions identity + loading hygiene', () => {
|
|||
|
||||
const first = $sessions.get()
|
||||
|
||||
listAllProfileSessions.mockResolvedValue({
|
||||
sessions: [row('a', { last_active: 2000, title: 'Renamed' })],
|
||||
total: 1,
|
||||
profile_totals: {}
|
||||
})
|
||||
listSidebarSessions.mockResolvedValue(
|
||||
sidebar({ sessions: [row('a', { last_active: 2000, title: 'Renamed' })], total: 1, profile_totals: {} })
|
||||
)
|
||||
|
||||
await act(async () => {
|
||||
await result.current.refreshSessions()
|
||||
|
|
@ -101,7 +118,7 @@ describe('refreshSessions identity + loading hygiene', () => {
|
|||
})
|
||||
|
||||
it('does not flicker the loading flag over a populated list', async () => {
|
||||
listAllProfileSessions.mockResolvedValue({ sessions: [row('a')], total: 1, profile_totals: {} })
|
||||
listSidebarSessions.mockResolvedValue(sidebar({ sessions: [row('a')], total: 1, profile_totals: {} }))
|
||||
const { result } = renderHook(() => useSessionListActions({ profileScope: 'default' }))
|
||||
|
||||
await act(async () => {
|
||||
|
|
@ -121,7 +138,7 @@ describe('refreshSessions identity + loading hygiene', () => {
|
|||
})
|
||||
|
||||
it('still shows loading for the initial (empty-list) fetch', async () => {
|
||||
listAllProfileSessions.mockResolvedValue({ sessions: [row('a')], total: 1, profile_totals: {} })
|
||||
listSidebarSessions.mockResolvedValue(sidebar({ sessions: [row('a')], total: 1, profile_totals: {} }))
|
||||
const { result } = renderHook(() => useSessionListActions({ profileScope: 'default' }))
|
||||
|
||||
const loadingStates: boolean[] = []
|
||||
|
|
@ -135,3 +152,45 @@ describe('refreshSessions identity + loading hygiene', () => {
|
|||
expect(loadingStates).toEqual([false, true, false])
|
||||
})
|
||||
})
|
||||
|
||||
describe('refreshSessions batches slices into one request', () => {
|
||||
it('makes a single sidebar call and distributes recents / cron / messaging', async () => {
|
||||
const recents = [row('a'), row('b')]
|
||||
const cron = [row('c1', { source: 'cron', title: 'nightly' })]
|
||||
const messaging = [row('m1', { source: 'telegram', title: 'tg chat' })]
|
||||
|
||||
listSidebarSessions.mockResolvedValue(sidebar({ sessions: recents, total: 2, profile_totals: { default: 2 } }, cron, messaging))
|
||||
|
||||
const { result } = renderHook(() => useSessionListActions({ profileScope: 'default' }))
|
||||
|
||||
await act(async () => {
|
||||
await result.current.refreshSessions()
|
||||
})
|
||||
|
||||
// One batched call, not three separate listAllProfileSessions reads.
|
||||
expect(listSidebarSessions).toHaveBeenCalledTimes(1)
|
||||
expect(listAllProfileSessions).not.toHaveBeenCalled()
|
||||
|
||||
// Each slice landed in its own store.
|
||||
expect($sessions.get().map(s => s.id)).toEqual(['a', 'b'])
|
||||
expect($cronSessions.get().map(s => s.id)).toEqual(['c1'])
|
||||
expect($messagingSessions.get().map(s => s.id)).toEqual(['m1'])
|
||||
})
|
||||
|
||||
it('forwards the active profile scope + section limits to the batched call', async () => {
|
||||
listSidebarSessions.mockResolvedValue(sidebar({ sessions: [], total: 0, profile_totals: {} }))
|
||||
const { result } = renderHook(() => useSessionListActions({ profileScope: 'work' }))
|
||||
|
||||
await act(async () => {
|
||||
await result.current.refreshSessions()
|
||||
})
|
||||
|
||||
expect(listSidebarSessions).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
recentsProfile: 'work',
|
||||
recentsExclude: expect.arrayContaining(['cron']),
|
||||
messagingExclude: expect.arrayContaining(['cron'])
|
||||
})
|
||||
)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { useCallback, useRef } from 'react'
|
||||
|
||||
import { getCronJobs, listAllProfileSessions, type SessionInfo } from '@/hermes'
|
||||
import { getCronJobs, listAllProfileSessions, listSidebarSessions, type SessionInfo } from '@/hermes'
|
||||
import { sameCronSignature } from '@/lib/session-signatures'
|
||||
import {
|
||||
isMessagingSource,
|
||||
|
|
@ -75,22 +75,6 @@ interface UseSessionListActionsArgs {
|
|||
export function useSessionListActions({ profileScope }: UseSessionListActionsArgs) {
|
||||
const refreshSessionsRequestRef = useRef(0)
|
||||
|
||||
// Cron-job sessions as their own list (latest N). Independent of the recents
|
||||
// page so the two never compete for slots. Cheap + bounded. Kept (even though
|
||||
// the sidebar now lists cron *jobs*, not run sessions) so a pinned cron run
|
||||
// still resolves into the Pinned section via sessionByAnyId.
|
||||
const refreshCronSessions = useCallback(async () => {
|
||||
try {
|
||||
const { sessions } = await listAllProfileSessions(CRON_SECTION_LIMIT, 1, 'exclude', 'recent', 'all', {
|
||||
source: 'cron'
|
||||
})
|
||||
|
||||
setCronSessions(prev => (sameCronSignature(prev, sessions) ? prev : sessions))
|
||||
} catch {
|
||||
// Non-fatal: the cron section just stays empty/stale.
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Messaging-platform sessions as their own slice, fetched separately from
|
||||
// local recents so each platform renders a self-managed section and never
|
||||
// competes with local chats for the recents page budget. One combined fetch
|
||||
|
|
@ -171,37 +155,59 @@ export function useSessionListActions({ profileScope }: UseSessionListActionsArg
|
|||
// clutter the sidebar.
|
||||
// Unified cross-profile list (served read-only off each profile's
|
||||
// state.db; no per-profile backend is spawned). Single-profile users get
|
||||
// the same rows tagged profile="default". Cron sessions are excluded here
|
||||
// and fetched separately (refreshCronSessions) so the scheduler's
|
||||
// always-newest rows can't consume the recents page budget.
|
||||
// Scope the fetch to the active profile (not always 'all') so a profile
|
||||
// the same rows tagged profile="default".
|
||||
// Scope recents to the active profile (not always 'all') so a profile
|
||||
// with few recent sessions isn't windowed out of the cross-profile
|
||||
// recency page — the empty-history-on-profile-switch bug.
|
||||
// recency page — the empty-history-on-profile-switch bug. Cron + messaging
|
||||
// stay cross-profile.
|
||||
const sessionProfile = profileScope === ALL_PROFILES ? 'all' : profileScope
|
||||
|
||||
const result = await listAllProfileSessions(limit, 1, 'exclude', 'recent', sessionProfile, {
|
||||
excludeSources: SIDEBAR_EXCLUDED_SOURCES
|
||||
// Batched: one request opens each profile DB once and returns all three
|
||||
// source-scoped slices, instead of three separate listAllProfileSessions
|
||||
// calls that each reopened + re-counted every profile DB per refresh.
|
||||
const result = await listSidebarSessions({
|
||||
recentsProfile: sessionProfile,
|
||||
recentsLimit: limit,
|
||||
recentsExclude: SIDEBAR_EXCLUDED_SOURCES,
|
||||
cronLimit: CRON_SECTION_LIMIT,
|
||||
messagingLimit: MESSAGING_SECTION_LIMIT,
|
||||
messagingExclude: MESSAGING_EXCLUDED_SOURCES
|
||||
})
|
||||
|
||||
if (refreshSessionsRequestRef.current === requestId) {
|
||||
const recents = result.recents
|
||||
|
||||
// Signature-gate the swap (same pattern as cron/messaging): a refresh
|
||||
// that returns content-identical rows must keep the previous array
|
||||
// identity, or every sidebar memo keyed on $sessions recomputes and the
|
||||
// whole list re-renders once per turn/broadcast for nothing.
|
||||
setSessions(prev => {
|
||||
const next = mergeSessionPage(prev, result.sessions, sessionsToKeep())
|
||||
const next = mergeSessionPage(prev, recents.sessions, sessionsToKeep())
|
||||
|
||||
return sameCronSignature(prev, next) ? prev : next
|
||||
})
|
||||
setSessionsTotal(typeof result.total === 'number' ? result.total : result.sessions.length)
|
||||
setSessionsTotal(typeof recents.total === 'number' ? recents.total : recents.sessions.length)
|
||||
setSessionProfileTotals(prev => {
|
||||
const next = result.profile_totals ?? {}
|
||||
const next = recents.profile_totals ?? {}
|
||||
const prevKeys = Object.keys(prev)
|
||||
|
||||
return prevKeys.length === Object.keys(next).length && prevKeys.every(key => prev[key] === next[key])
|
||||
? prev
|
||||
: next
|
||||
})
|
||||
|
||||
// Cron section: latest N cron sessions (kept so a pinned cron run still
|
||||
// resolves via sessionByAnyId), signature-gated like above.
|
||||
setCronSessions(prev => (sameCronSignature(prev, result.cron.sessions) ? prev : result.cron.sessions))
|
||||
|
||||
// Messaging sections: drop any non-messaging source the broad exclude
|
||||
// didn't catch (custom sources stay in local recents), then split per
|
||||
// platform in the UI.
|
||||
const messagingRows = result.messaging.sessions.filter(s => isMessagingSource(s.source))
|
||||
|
||||
setMessagingSessions(prev => (sameCronSignature(prev, messagingRows) ? prev : messagingRows))
|
||||
// Hit the cap → at least one platform may have more on disk than loaded.
|
||||
setMessagingTruncated(result.messaging.sessions.length >= MESSAGING_SECTION_LIMIT)
|
||||
}
|
||||
} finally {
|
||||
if (showLoading && refreshSessionsRequestRef.current === requestId) {
|
||||
|
|
@ -209,10 +215,9 @@ export function useSessionListActions({ profileScope }: UseSessionListActionsArg
|
|||
}
|
||||
}
|
||||
|
||||
void refreshCronSessions()
|
||||
// Cron *jobs* are a distinct API (getCronJobs), not a session slice.
|
||||
void refreshCronJobs()
|
||||
void refreshMessagingSessions()
|
||||
}, [profileScope, refreshCronSessions, refreshCronJobs, refreshMessagingSessions])
|
||||
}, [profileScope, refreshCronJobs])
|
||||
|
||||
const loadMoreSessions = useCallback(async () => {
|
||||
bumpSessionsLimit()
|
||||
|
|
|
|||
|
|
@ -10,7 +10,8 @@ import {
|
|||
getSessionMessages,
|
||||
getStatus,
|
||||
listAllProfileSessions,
|
||||
listSessions
|
||||
listSessions,
|
||||
listSidebarSessions
|
||||
} from './hermes'
|
||||
import { refreshActiveProfile } from './store/profile'
|
||||
|
||||
|
|
@ -59,6 +60,45 @@ describe('Hermes REST session helpers', () => {
|
|||
)
|
||||
})
|
||||
|
||||
it('batches the sidebar slices into a single request with per-slice limits + excludes', async () => {
|
||||
api.mockResolvedValue({ recents: { sessions: [] }, cron: { sessions: [] }, messaging: { sessions: [] } })
|
||||
|
||||
await listSidebarSessions({
|
||||
recentsProfile: 'work',
|
||||
recentsLimit: 30,
|
||||
recentsExclude: ['cron', 'tool'],
|
||||
cronLimit: 50,
|
||||
messagingLimit: 100,
|
||||
messagingExclude: ['cron', 'desktop']
|
||||
})
|
||||
|
||||
expect(api).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
path:
|
||||
'/api/profiles/sessions/sidebar?recents_profile=work&recents_limit=30&cron_limit=50' +
|
||||
'&messaging_limit=100&recents_exclude=cron%2Ctool&messaging_exclude=cron%2Cdesktop',
|
||||
timeoutMs: 60_000
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('defaults missing sidebar slices to empty session arrays', async () => {
|
||||
api.mockResolvedValue({})
|
||||
|
||||
const result = await listSidebarSessions({
|
||||
recentsProfile: 'all',
|
||||
recentsLimit: 20,
|
||||
recentsExclude: [],
|
||||
cronLimit: 50,
|
||||
messagingLimit: 100,
|
||||
messagingExclude: []
|
||||
})
|
||||
|
||||
expect(result.recents.sessions).toEqual([])
|
||||
expect(result.cron.sessions).toEqual([])
|
||||
expect(result.messaging.sessions).toEqual([])
|
||||
})
|
||||
|
||||
it('uses a longer timeout for profile listing during desktop startup', async () => {
|
||||
api.mockResolvedValue({ profiles: [] })
|
||||
|
||||
|
|
|
|||
|
|
@ -359,6 +359,62 @@ export async function listAllProfileSessions(
|
|||
}
|
||||
}
|
||||
|
||||
// Batched sidebar slices in one request: recents (scoped to the active profile),
|
||||
// cron, and messaging. The backend opens each profile's state.db once and runs
|
||||
// all three filtered queries, replacing three separate listAllProfileSessions
|
||||
// calls that each reopened + re-counted every profile DB per refresh. Electron
|
||||
// splices remote profiles per slice (see interceptSessionRequestForRemote).
|
||||
export interface SidebarSessionSlice {
|
||||
sessions: SessionInfo[]
|
||||
total?: number
|
||||
profile_totals?: Record<string, number>
|
||||
}
|
||||
|
||||
export interface SidebarSessionsResponse {
|
||||
recents: SidebarSessionSlice
|
||||
cron: SidebarSessionSlice
|
||||
messaging: SidebarSessionSlice
|
||||
errors?: Array<{ profile: string; error: string }>
|
||||
}
|
||||
|
||||
export interface SidebarSessionsRequest {
|
||||
recentsProfile: 'all' | (string & {})
|
||||
recentsLimit: number
|
||||
recentsExclude: string[]
|
||||
cronLimit: number
|
||||
messagingLimit: number
|
||||
messagingExclude: string[]
|
||||
}
|
||||
|
||||
export async function listSidebarSessions(req: SidebarSessionsRequest): Promise<SidebarSessionsResponse> {
|
||||
const params = new URLSearchParams({
|
||||
recents_profile: req.recentsProfile,
|
||||
recents_limit: String(Math.max(1, req.recentsLimit)),
|
||||
cron_limit: String(Math.max(1, req.cronLimit)),
|
||||
messaging_limit: String(Math.max(1, req.messagingLimit))
|
||||
})
|
||||
|
||||
if (req.recentsExclude.length) {
|
||||
params.set('recents_exclude', req.recentsExclude.join(','))
|
||||
}
|
||||
|
||||
if (req.messagingExclude.length) {
|
||||
params.set('messaging_exclude', req.messagingExclude.join(','))
|
||||
}
|
||||
|
||||
const result = await window.hermesDesktop.api<SidebarSessionsResponse>({
|
||||
path: `/api/profiles/sessions/sidebar?${params.toString()}`,
|
||||
timeoutMs: SESSION_LIST_REQUEST_TIMEOUT_MS
|
||||
})
|
||||
|
||||
return {
|
||||
recents: { ...result.recents, sessions: result.recents?.sessions ?? [] },
|
||||
cron: { ...result.cron, sessions: result.cron?.sessions ?? [] },
|
||||
messaging: { ...result.messaging, sessions: result.messaging?.sessions ?? [] },
|
||||
errors: result.errors
|
||||
}
|
||||
}
|
||||
|
||||
// Mutations take the owning `profile` so Electron routes them to that profile's
|
||||
// backend (remote pool or local primary) via request.profile — matching the
|
||||
// read path. A remote session's row lives only on its remote host, so a mutation
|
||||
|
|
|
|||
|
|
@ -4266,6 +4266,139 @@ def get_profiles_sessions(
|
|||
}
|
||||
|
||||
|
||||
@app.get("/api/profiles/sessions/sidebar")
|
||||
def get_profiles_sessions_sidebar(
|
||||
recents_profile: str = "all",
|
||||
recents_limit: int = 20,
|
||||
recents_exclude: str = None,
|
||||
cron_limit: int = 50,
|
||||
messaging_limit: int = 100,
|
||||
messaging_exclude: str = None,
|
||||
):
|
||||
"""Batched sidebar session slices — one profile-DB open per refresh.
|
||||
|
||||
The desktop sidebar needs three source-scoped windows per refresh: recents
|
||||
(local chats, scoped to the active profile), cron sessions (all profiles),
|
||||
and messaging-platform sessions (all profiles). Served as three separate
|
||||
``/api/profiles/sessions`` calls they reopened every profile's ``state.db``
|
||||
three times and re-counted each refresh. This opens each DB once and runs
|
||||
the three filtered queries together, returning the three windows in one
|
||||
payload. Read-only and process-light, same row projection and 300s active
|
||||
heuristic as ``/api/profiles/sessions``.
|
||||
|
||||
The caller passes the source taxonomy (``recents_exclude`` /
|
||||
``messaging_exclude`` CSV, ``source=cron`` is implicit) so this stays
|
||||
taxonomy-agnostic like the per-slice endpoint. All three slices use
|
||||
``min_messages=1`` / ``archived=exclude`` / recency order, matching the
|
||||
desktop's per-slice calls.
|
||||
"""
|
||||
from hermes_state import SessionDB
|
||||
from hermes_cli import profiles as profiles_mod
|
||||
|
||||
# cron + messaging are cross-profile; recents is scoped to recents_profile.
|
||||
# Scan every profile once regardless (each DB opened a single time).
|
||||
try:
|
||||
infos = profiles_mod.list_profiles()
|
||||
targets: List[Tuple[str, Path]] = [(info.name, info.path) for info in infos]
|
||||
except Exception:
|
||||
_log.exception("GET /api/profiles/sessions/sidebar: list_profiles failed")
|
||||
targets = []
|
||||
if not targets:
|
||||
targets.append(("default", profiles_mod.get_profile_dir("default")))
|
||||
|
||||
recents_scope = (recents_profile or "all").strip() or "all"
|
||||
recents_exclude_list = [s for s in (recents_exclude or "").split(",") if s.strip()]
|
||||
messaging_exclude_list = [s for s in (messaging_exclude or "").split(",") if s.strip()]
|
||||
|
||||
recents_cap = min(max(recents_limit, 1), 500)
|
||||
cron_cap = min(max(cron_limit, 1), 500)
|
||||
messaging_cap = min(max(messaging_limit, 1), 500)
|
||||
|
||||
recents_rows: List[Dict[str, Any]] = []
|
||||
cron_rows: List[Dict[str, Any]] = []
|
||||
messaging_rows: List[Dict[str, Any]] = []
|
||||
recents_total = 0
|
||||
recents_profile_totals: Dict[str, int] = {}
|
||||
errors: List[Dict[str, str]] = []
|
||||
now = time.time()
|
||||
|
||||
def _tag(rows: List[Dict[str, Any]], name: str) -> List[Dict[str, Any]]:
|
||||
for s in rows:
|
||||
s["profile"] = name
|
||||
s["is_default_profile"] = name == "default"
|
||||
s["is_active"] = (
|
||||
s.get("ended_at") is None
|
||||
and (now - s.get("last_active", s.get("started_at", 0))) < 300
|
||||
)
|
||||
s["archived"] = bool(s.get("archived"))
|
||||
return rows
|
||||
|
||||
def _slice(db, *, source=None, exclude=None, cap):
|
||||
return db.list_sessions_rich(
|
||||
source=source,
|
||||
exclude_sources=exclude or None,
|
||||
limit=cap,
|
||||
offset=0,
|
||||
min_message_count=1,
|
||||
include_archived=False,
|
||||
archived_only=False,
|
||||
order_by_last_active=True,
|
||||
compact_rows=True,
|
||||
)
|
||||
|
||||
for name, home in targets:
|
||||
db_path = Path(home) / "state.db"
|
||||
if not db_path.exists():
|
||||
continue
|
||||
try:
|
||||
db = SessionDB(db_path=db_path, read_only=True)
|
||||
except Exception as exc:
|
||||
errors.append({"profile": name, "error": str(exc)})
|
||||
continue
|
||||
try:
|
||||
if recents_scope == "all" or name == recents_scope:
|
||||
recents_rows.extend(
|
||||
_tag(_slice(db, exclude=recents_exclude_list, cap=recents_cap), name)
|
||||
)
|
||||
rtotal = db.session_count(
|
||||
exclude_sources=recents_exclude_list or None,
|
||||
min_message_count=1,
|
||||
include_archived=False,
|
||||
archived_only=False,
|
||||
exclude_children=True,
|
||||
)
|
||||
recents_total += rtotal
|
||||
recents_profile_totals[name] = rtotal
|
||||
cron_rows.extend(_tag(_slice(db, source="cron", cap=cron_cap), name))
|
||||
messaging_rows.extend(
|
||||
_tag(_slice(db, exclude=messaging_exclude_list, cap=messaging_cap), name)
|
||||
)
|
||||
except Exception as exc:
|
||||
errors.append({"profile": name, "error": str(exc)})
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
def _window(rows: List[Dict[str, Any]], cap: int) -> List[Dict[str, Any]]:
|
||||
rows.sort(key=lambda s: s.get("last_active") or s.get("started_at") or 0, reverse=True)
|
||||
win = rows[:cap]
|
||||
_strip_session_list_rows(win)
|
||||
return win
|
||||
|
||||
return {
|
||||
"recents": {
|
||||
"sessions": _window(recents_rows, recents_cap),
|
||||
"total": recents_total,
|
||||
"profile_totals": recents_profile_totals,
|
||||
},
|
||||
"cron": {"sessions": _window(cron_rows, cron_cap)},
|
||||
"messaging": {
|
||||
"sessions": _window(messaging_rows, messaging_cap),
|
||||
"total": len(messaging_rows),
|
||||
},
|
||||
"errors": errors,
|
||||
}
|
||||
|
||||
|
||||
@app.get("/api/sessions/search")
|
||||
async def search_sessions(q: str = "", limit: int = 20, profile: Optional[str] = None):
|
||||
"""Search sessions by ID plus full-text message content using FTS5.
|
||||
|
|
|
|||
|
|
@ -1473,6 +1473,52 @@ class TestWebServerEndpoints:
|
|||
resp = self.client.get("/api/profiles/sessions?archived=bogus")
|
||||
assert resp.status_code == 400
|
||||
|
||||
def test_profiles_sessions_sidebar_batches_three_slices(self):
|
||||
"""The batched sidebar endpoint returns recents/cron/messaging in one
|
||||
pass, each source-scoped by the caller-supplied excludes, so the desktop
|
||||
stops reopening every profile DB three times per refresh."""
|
||||
from hermes_state import SessionDB
|
||||
|
||||
db = SessionDB()
|
||||
try:
|
||||
for sid, src in (
|
||||
("sb-desktop", "desktop"),
|
||||
("sb-cron", "cron"),
|
||||
("sb-telegram", "telegram"),
|
||||
):
|
||||
db.create_session(session_id=sid, source=src)
|
||||
db.append_message(session_id=sid, role="user", content="hi")
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
resp = self.client.get(
|
||||
"/api/profiles/sessions/sidebar"
|
||||
"?recents_profile=all&recents_limit=20&recents_exclude=cron,telegram"
|
||||
"&cron_limit=50&messaging_limit=100"
|
||||
"&messaging_exclude=cron,cli,codex,desktop,gateway,local,tui"
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
|
||||
recents_ids = {s["id"] for s in data["recents"]["sessions"]}
|
||||
cron_ids = {s["id"] for s in data["cron"]["sessions"]}
|
||||
messaging_ids = {s["id"] for s in data["messaging"]["sessions"]}
|
||||
|
||||
# Each session lands only in its own slice.
|
||||
assert "sb-desktop" in recents_ids
|
||||
assert "sb-desktop" not in cron_ids and "sb-desktop" not in messaging_ids
|
||||
assert "sb-cron" in cron_ids
|
||||
assert "sb-cron" not in recents_ids and "sb-cron" not in messaging_ids
|
||||
assert "sb-telegram" in messaging_ids
|
||||
assert "sb-telegram" not in recents_ids and "sb-telegram" not in cron_ids
|
||||
|
||||
# Rows carry profile tagging like /api/profiles/sessions.
|
||||
row = next(s for s in data["recents"]["sessions"] if s["id"] == "sb-desktop")
|
||||
assert row["profile"] == "default"
|
||||
assert row["is_default_profile"] is True
|
||||
assert isinstance(data.get("errors"), list)
|
||||
assert data["recents"]["total"] >= 1
|
||||
|
||||
def test_sessions_endpoint_reads_requested_profile(self):
|
||||
"""The machine dashboard's global profile switcher must retarget
|
||||
the Sessions page, not just config/skills/model pages."""
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue