feat: add session search modal (#128)

This commit is contained in:
cl1107
2026-04-22 14:00:34 +08:00
committed by GitHub
parent ffd825afe2
commit f27db3036a
18 changed files with 1355 additions and 126 deletions
+211
View File
@@ -0,0 +1,211 @@
// @vitest-environment jsdom
import { nextTick, defineComponent, h } from 'vue'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { mount } from '@vue/test-utils'
const apiMocks = vi.hoisted(() => ({
fetchSessionsMock: vi.fn(),
searchSessionsMock: vi.fn(),
routerPushMock: vi.fn(),
}))
vi.mock('@/api/hermes/sessions', () => ({
fetchSessions: apiMocks.fetchSessionsMock,
searchSessions: apiMocks.searchSessionsMock,
}))
const chatStoreMock = vi.hoisted(() => ({
sessions: [] as Array<Record<string, any>>,
loadSessions: vi.fn(),
switchSession: vi.fn(),
newChat: vi.fn(),
}))
vi.mock('@/stores/hermes/chat', () => ({
useChatStore: () => chatStoreMock,
}))
const routerCurrentRoute = { value: { name: 'hermes.logs' } }
vi.mock('vue-router', () => ({
useRouter: () => ({
currentRoute: routerCurrentRoute,
push: apiMocks.routerPushMock,
}),
}))
vi.mock('vue-i18n', () => ({
useI18n: () => ({
t: (key: string) => key,
}),
}))
vi.mock('naive-ui', async () => {
const actual = await vi.importActual<any>('naive-ui')
return {
...actual,
useMessage: () => ({
error: vi.fn(),
}),
NModal: {
props: ['show'],
emits: ['update:show'],
template: '<div v-if="show" class="n-modal-stub"><slot /></div>',
},
NInput: {
props: ['value', 'size'],
emits: ['update:value', 'keydown'],
template: '<input class="n-input-stub" :value="value" @input="$emit(\'update:value\', $event.target.value)" @keydown="$emit(\'keydown\', $event)" />',
},
NSpin: {
template: '<div class="n-spin-stub"><slot /></div>',
},
NButton: {
template: '<button class="n-button-stub"><slot /></button>',
},
}
})
import SessionSearchModal from '@/components/hermes/chat/SessionSearchModal.vue'
import { useSessionSearch } from '@/composables/useSessionSearch'
import { useKeyboard } from '@/composables/useKeyboard'
function flushPromises() {
return Promise.resolve().then(() => Promise.resolve())
}
describe('session search modal', () => {
beforeEach(() => {
vi.useFakeTimers()
vi.clearAllMocks()
chatStoreMock.sessions = []
chatStoreMock.loadSessions.mockResolvedValue(undefined)
chatStoreMock.switchSession.mockResolvedValue(undefined)
apiMocks.fetchSessionsMock.mockResolvedValue([
{
id: 'recent-1',
source: 'cli',
model: 'openai/gpt-5.4',
title: 'Recent Docker fix',
preview: 'recent preview',
started_at: 1710000000,
ended_at: 1710000001,
last_active: 1710000002,
message_count: 2,
tool_call_count: 0,
input_tokens: 1,
output_tokens: 2,
cache_read_tokens: 0,
cache_write_tokens: 0,
reasoning_tokens: 0,
billing_provider: 'openrouter',
estimated_cost_usd: 0,
actual_cost_usd: 0,
cost_status: 'estimated',
},
])
apiMocks.searchSessionsMock.mockResolvedValue([
{
id: 'match-1',
source: 'telegram',
model: 'openai/gpt-5.4',
title: 'Debugging session',
preview: 'search preview',
started_at: 1710001000,
ended_at: null,
last_active: 1710001005,
message_count: 4,
tool_call_count: 1,
input_tokens: 3,
output_tokens: 4,
cache_read_tokens: 0,
cache_write_tokens: 0,
reasoning_tokens: 0,
billing_provider: 'openrouter',
estimated_cost_usd: 0,
actual_cost_usd: 0,
cost_status: 'estimated',
matched_message_id: 17,
snippet: 'docker compose up',
rank: 0.1,
},
])
routerCurrentRoute.value = { name: 'hermes.logs' }
})
afterEach(() => {
vi.useRealTimers()
})
it('opens from Cmd/Ctrl+K and loads recent sessions', async () => {
const { openSessionSearch, sessionSearchOpen } = useSessionSearch()
const wrapper = mount(SessionSearchModal, {
global: {
stubs: {
NModal: false,
NInput: false,
NSpin: false,
NButton: false,
},
},
})
openSessionSearch()
await flushPromises()
await nextTick()
expect(sessionSearchOpen.value).toBe(true)
expect(apiMocks.fetchSessionsMock).toHaveBeenCalledWith(undefined, 8)
expect(wrapper.text()).toContain('Recent Docker fix')
})
it('searches by content and opens the matched session', async () => {
const { openSessionSearch } = useSessionSearch()
const wrapper = mount(SessionSearchModal)
openSessionSearch()
await flushPromises()
await nextTick()
const input = wrapper.find('input.n-input-stub')
await input.setValue('docker')
await vi.advanceTimersByTimeAsync(200)
await flushPromises()
await nextTick()
expect(apiMocks.searchSessionsMock).toHaveBeenCalledWith('docker', undefined, 10)
expect(wrapper.text()).toContain('Debugging session')
await wrapper.find('button.result-item').trigger('click')
await flushPromises()
expect(chatStoreMock.loadSessions).toHaveBeenCalled()
expect(chatStoreMock.switchSession).toHaveBeenCalledWith('match-1', '17')
expect(apiMocks.routerPushMock).toHaveBeenCalledWith({ name: 'hermes.chat' })
})
})
describe('keyboard shortcut', () => {
beforeEach(() => {
vi.clearAllMocks()
const { closeSessionSearch } = useSessionSearch()
closeSessionSearch()
chatStoreMock.newChat.mockReset()
})
it('opens session search on Cmd/Ctrl+K', async () => {
const Dummy = defineComponent({
setup() {
useKeyboard()
return () => h('div')
},
})
mount(Dummy)
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'k', metaKey: true }))
await nextTick()
expect(useSessionSearch().sessionSearchOpen.value).toBe(true)
})
})
+93
View File
@@ -0,0 +1,93 @@
// @vitest-environment jsdom
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { mount } from '@vue/test-utils'
const openSessionSearchMock = vi.hoisted(() => vi.fn())
vi.mock('@/composables/useSessionSearch', () => ({
useSessionSearch: () => ({
openSessionSearch: openSessionSearchMock,
}),
}))
vi.mock('@/stores/hermes/app', () => ({
useAppStore: () => ({
sidebarOpen: true,
connected: true,
serverVersion: 'test',
updateAvailable: false,
updating: false,
toggleSidebar: vi.fn(),
closeSidebar: vi.fn(),
doUpdate: vi.fn(),
}),
}))
vi.mock('vue-router', async (importOriginal) => {
const actual = await importOriginal<any>()
return {
...actual,
useRoute: () => ({ name: 'hermes.chat' }),
useRouter: () => ({ push: vi.fn() }),
}
})
vi.mock('vue-i18n', () => ({
useI18n: () => ({
t: (key: string) => key,
}),
}))
vi.mock('@/composables/useTheme', () => ({
useTheme: () => ({ isDark: false }),
}))
vi.mock('/logo.png', () => ({
default: 'logo.png',
}))
vi.mock('naive-ui', async () => {
const actual = await vi.importActual<any>('naive-ui')
return {
...actual,
useMessage: () => ({
success: vi.fn(),
error: vi.fn(),
}),
NButton: {
template: '<button><slot /></button>',
},
NSelect: {
template: '<div />',
},
}
})
import AppSidebar from '@/components/layout/AppSidebar.vue'
describe('AppSidebar search entry', () => {
beforeEach(() => {
openSessionSearchMock.mockClear()
})
it('opens the session search modal from the sidebar button', async () => {
const wrapper = mount(AppSidebar, {
global: {
stubs: {
ProfileSelector: true,
ModelSelector: true,
LanguageSwitch: true,
ThemeSwitch: true,
NButton: true,
},
},
})
const buttons = wrapper.findAll('button')
const searchButton = buttons.find(node => node.text().includes('sidebar.search'))
expect(searchButton).toBeTruthy()
await searchButton!.trigger('click')
expect(openSessionSearchMock).toHaveBeenCalledTimes(1)
})
})
+152 -1
View File
@@ -1,7 +1,15 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const allMock = vi.fn()
const prepareMock = vi.fn(() => ({ all: allMock }))
const titleAllMock = vi.fn()
const contentAllMock = vi.fn()
const likeAllMock = vi.fn()
const prepareMock = vi.fn((sql: string) => {
if (sql.includes('messages_fts MATCH')) return ({ all: contentAllMock })
if (sql.includes('m.content LIKE ?')) return ({ all: likeAllMock })
if (sql.includes("LOWER(COALESCE(base.title, '')) LIKE ?")) return ({ all: titleAllMock })
return ({ all: allMock })
})
const closeMock = vi.fn()
const databaseSyncMock = vi.fn(() => ({ prepare: prepareMock, close: closeMock }))
const getActiveProfileDirMock = vi.fn(() => '/tmp/hermes-profile')
@@ -18,6 +26,9 @@ describe('session DB summaries', () => {
beforeEach(() => {
vi.resetModules()
allMock.mockReset()
titleAllMock.mockReset()
contentAllMock.mockReset()
likeAllMock.mockReset()
prepareMock.mockClear()
closeMock.mockClear()
databaseSyncMock.mockClear()
@@ -122,4 +133,144 @@ describe('session DB summaries', () => {
expect(rows[0].source).toBe('telegram')
expect(rows[0].title).toBe('preview text')
})
it('searches session titles and content with deduped results', async () => {
titleAllMock.mockReturnValue([
{
id: 'title-1',
source: 'cli',
user_id: '',
model: 'openai/gpt-5.4',
title: 'Docker debugging',
started_at: 1710001000,
ended_at: null,
end_reason: '',
message_count: 2,
tool_call_count: 0,
input_tokens: 1,
output_tokens: 2,
cache_read_tokens: 0,
cache_write_tokens: 0,
reasoning_tokens: 0,
billing_provider: '',
estimated_cost_usd: 0,
actual_cost_usd: null,
cost_status: '',
preview: 'title preview',
last_active: 1710001005,
matched_message_id: null,
snippet: 'Docker debugging',
rank: 0,
},
])
contentAllMock.mockReturnValue([
{
id: 'title-1',
source: 'cli',
user_id: '',
model: 'openai/gpt-5.4',
title: 'Docker debugging',
started_at: 1710001000,
ended_at: null,
end_reason: '',
message_count: 2,
tool_call_count: 0,
input_tokens: 1,
output_tokens: 2,
cache_read_tokens: 0,
cache_write_tokens: 0,
reasoning_tokens: 0,
billing_provider: '',
estimated_cost_usd: 0,
actual_cost_usd: null,
cost_status: '',
preview: 'title preview',
last_active: 1710001005,
matched_message_id: 42,
snippet: '>>>docker<<< compose up',
rank: 0.25,
},
{
id: 'content-2',
source: 'telegram',
user_id: '',
model: 'openai/gpt-5.4',
title: '',
started_at: 1710002000,
ended_at: null,
end_reason: '',
message_count: 1,
tool_call_count: 0,
input_tokens: 3,
output_tokens: 4,
cache_read_tokens: 0,
cache_write_tokens: 0,
reasoning_tokens: 0,
billing_provider: '',
estimated_cost_usd: 0,
actual_cost_usd: null,
cost_status: '',
preview: 'content preview',
last_active: 1710002001,
matched_message_id: 7,
snippet: '>>>docker<<< swarm',
rank: 0.1,
},
])
const mod = await import('../../packages/server/src/services/hermes/sessions-db')
const rows = await mod.searchSessionSummaries('docker', undefined, 10)
expect(prepareMock).toHaveBeenCalledWith(expect.stringContaining('messages_fts MATCH'))
expect(rows).toHaveLength(2)
expect(rows[0].id).toBe('title-1')
expect(rows[0].matched_message_id).toBeNull()
expect(rows[0].snippet).toBe('Docker debugging')
expect(rows[1].id).toBe('content-2')
expect(rows[1].matched_message_id).toBe(7)
expect(rows[1].snippet).toContain('docker')
})
it('falls back to LIKE search for CJK queries', async () => {
titleAllMock.mockReturnValue([])
contentAllMock.mockImplementation(() => {
throw new Error('fts5 tokenizer miss')
})
likeAllMock.mockReturnValue([
{
id: 'cjk-1',
source: 'cli',
user_id: '',
model: 'openai/gpt-5.4',
title: '',
started_at: 1710003000,
ended_at: null,
end_reason: '',
message_count: 1,
tool_call_count: 0,
input_tokens: 3,
output_tokens: 4,
cache_read_tokens: 0,
cache_write_tokens: 0,
reasoning_tokens: 0,
billing_provider: '',
estimated_cost_usd: 0,
actual_cost_usd: null,
cost_status: '',
preview: '中文预览',
last_active: 1710003002,
matched_message_id: 11,
snippet: '这是一段记忆断裂的内容',
rank: 0,
},
])
const mod = await import('../../packages/server/src/services/hermes/sessions-db')
const rows = await mod.searchSessionSummaries('记忆断裂', undefined, 10)
expect(likeAllMock).toHaveBeenCalledWith('记忆断裂', '%记忆断裂%')
expect(rows).toHaveLength(1)
expect(rows[0].id).toBe('cjk-1')
expect(rows[0].snippet).toContain('记忆断裂')
})
})
+55 -79
View File
@@ -1,111 +1,87 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const listSessionSummariesMock = vi.fn()
const listSessionsMock = vi.fn()
const listConversationSummariesMock = vi.fn()
const getConversationDetailMock = vi.fn()
const listConversationsMock = vi.fn(async (ctx: any) => { ctx.body = { sessions: [{ id: 'conversation-1' }] } })
const getConversationMessagesMock = vi.fn(async (ctx: any) => { ctx.body = { session_id: ctx.params.id, messages: [] } })
const listMock = vi.fn(async (ctx: any) => { ctx.body = { sessions: [{ id: 's1' }] } })
const searchMock = vi.fn(async (ctx: any) => { ctx.body = { results: [{ id: 'search-1' }] } })
const getMock = vi.fn(async (ctx: any) => { ctx.body = { session: { id: ctx.params.id } } })
const removeMock = vi.fn(async (ctx: any) => { ctx.body = { ok: true } })
const renameMock = vi.fn(async (ctx: any) => { ctx.body = { ok: true } })
vi.mock('../../packages/server/src/services/hermes/sessions-db', () => ({
listSessionSummaries: listSessionSummariesMock,
}))
vi.mock('../../packages/server/src/services/hermes/conversations', () => ({
listConversationSummaries: listConversationSummariesMock,
getConversationDetail: getConversationDetailMock,
}))
vi.mock('../../packages/server/src/services/hermes/hermes-cli', () => ({
listSessions: listSessionsMock,
getSession: vi.fn(),
deleteSession: vi.fn(),
renameSession: vi.fn(),
vi.mock('../../packages/server/src/controllers/hermes/sessions', () => ({
listConversations: listConversationsMock,
getConversationMessages: getConversationMessagesMock,
list: listMock,
search: searchMock,
get: getMock,
remove: removeMock,
rename: renameMock,
}))
describe('session routes', () => {
beforeEach(() => {
vi.resetModules()
listSessionSummariesMock.mockReset()
listSessionsMock.mockReset()
listConversationSummariesMock.mockReset()
getConversationDetailMock.mockReset()
listConversationsMock.mockClear()
getConversationMessagesMock.mockClear()
listMock.mockClear()
searchMock.mockClear()
getMock.mockClear()
removeMock.mockClear()
renameMock.mockClear()
})
it('serves summaries from sqlite-backed helper when available', async () => {
listSessionSummariesMock.mockResolvedValue([{ id: 's1' }])
it('registers conversations, session list, and search routes', async () => {
const { sessionRoutes } = await import('../../packages/server/src/routes/hermes/sessions')
const layer = sessionRoutes.stack.find((entry: any) => entry.path === '/api/hermes/sessions')
const paths = sessionRoutes.stack.map((entry: any) => entry.path)
expect(paths).toEqual(expect.arrayContaining([
'/api/hermes/sessions/conversations',
'/api/hermes/sessions/conversations/:id/messages',
'/api/hermes/sessions',
'/api/hermes/search/sessions',
'/api/hermes/sessions/search',
'/api/hermes/sessions/:id',
'/api/hermes/sessions/:id/rename',
]))
})
it('delegates session search to the controller', async () => {
const { sessionRoutes } = await import('../../packages/server/src/routes/hermes/sessions')
const layer = sessionRoutes.stack.find((entry: any) => entry.path === '/api/hermes/search/sessions')
const handler = layer.stack[0]
const ctx: any = { query: { source: 'cli', limit: '5' }, body: null }
const ctx: any = { query: { q: 'docker', limit: '8' }, body: null, params: {} }
await handler(ctx)
expect(listSessionSummariesMock).toHaveBeenCalledWith('cli', 5)
expect(listSessionsMock).not.toHaveBeenCalled()
expect(ctx.body).toEqual({ sessions: [{ id: 's1' }] })
expect(searchMock).toHaveBeenCalledWith(ctx)
expect(ctx.body).toEqual({ results: [{ id: 'search-1' }] })
})
it('falls back to CLI wrapper when sqlite summary query fails', async () => {
listSessionSummariesMock.mockRejectedValue(new Error('sqlite unavailable'))
listSessionsMock.mockResolvedValue([{ id: 'fallback' }])
it('keeps the legacy search path wired to the same controller', async () => {
const { sessionRoutes } = await import('../../packages/server/src/routes/hermes/sessions')
const layer = sessionRoutes.stack.find((entry: any) => entry.path === '/api/hermes/sessions')
const layer = sessionRoutes.stack.find((entry: any) => entry.path === '/api/hermes/sessions/search')
const handler = layer.stack[0]
const ctx: any = { query: { limit: '7' }, body: null }
const ctx: any = { query: { q: 'docker' }, body: null, params: {} }
await handler(ctx)
expect(listSessionSummariesMock).toHaveBeenCalledWith(undefined, 7)
expect(listSessionsMock).toHaveBeenCalledWith(undefined, 7)
expect(ctx.body).toEqual({ sessions: [{ id: 'fallback' }] })
expect(searchMock).toHaveBeenCalledWith(ctx)
expect(ctx.body).toEqual({ results: [{ id: 'search-1' }] })
})
it('serves live conversations with humanOnly defaulting to true', async () => {
listConversationSummariesMock.mockResolvedValue([{ id: 'conversation-1' }])
const { sessionRoutes } = await import('../../packages/server/src/routes/hermes/sessions')
const layer = sessionRoutes.stack.find((entry: any) => entry.path === '/api/hermes/sessions/conversations')
const handler = layer.stack[0]
const ctx: any = { query: {}, body: null }
await handler(ctx)
expect(listConversationSummariesMock).toHaveBeenCalledWith({ humanOnly: true, source: undefined, limit: undefined })
expect(ctx.body).toEqual({ sessions: [{ id: 'conversation-1' }] })
})
it('supports disabling humanOnly and forwarding limit/source for live conversations', async () => {
listConversationSummariesMock.mockResolvedValue([{ id: 'child-session' }])
it('delegates conversations list and detail routes', async () => {
const { sessionRoutes } = await import('../../packages/server/src/routes/hermes/sessions')
const listLayer = sessionRoutes.stack.find((entry: any) => entry.path === '/api/hermes/sessions/conversations')
const listCtx: any = { query: { humanOnly: 'false', source: 'cli', limit: '25' }, body: null }
await listLayer.stack[0](listCtx)
expect(listConversationSummariesMock).toHaveBeenCalledWith({ humanOnly: false, source: 'cli', limit: 25 })
expect(listCtx.body).toEqual({ sessions: [{ id: 'child-session' }] })
})
it('returns conversation detail and forwards humanOnly/source', async () => {
getConversationDetailMock.mockResolvedValue({ session_id: 'child-session', messages: [] })
const { sessionRoutes } = await import('../../packages/server/src/routes/hermes/sessions')
const detailLayer = sessionRoutes.stack.find((entry: any) => entry.path === '/api/hermes/sessions/conversations/:id/messages')
const detailCtx: any = { params: { id: 'child-session' }, query: { humanOnly: 'false', source: 'discord' }, body: null, status: 200 }
await detailLayer.stack[0](detailCtx)
const listCtx: any = { query: {}, body: null, params: {} }
await listLayer.stack[0](listCtx)
expect(listConversationsMock).toHaveBeenCalledWith(listCtx)
expect(listCtx.body).toEqual({ sessions: [{ id: 'conversation-1' }] })
expect(getConversationDetailMock).toHaveBeenCalledWith('child-session', { humanOnly: false, source: 'discord' })
const detailCtx: any = { params: { id: 'child-session' }, query: {}, body: null }
await detailLayer.stack[0](detailCtx)
expect(getConversationMessagesMock).toHaveBeenCalledWith(detailCtx)
expect(detailCtx.body).toEqual({ session_id: 'child-session', messages: [] })
})
it('returns 404 when a conversation detail is not found', async () => {
getConversationDetailMock.mockResolvedValue(null)
const { sessionRoutes } = await import('../../packages/server/src/routes/hermes/sessions')
const detailLayer = sessionRoutes.stack.find((entry: any) => entry.path === '/api/hermes/sessions/conversations/:id/messages')
const detailCtx: any = { params: { id: 'missing' }, query: {}, body: null, status: 200 }
await detailLayer.stack[0](detailCtx)
expect(getConversationDetailMock).toHaveBeenCalledWith('missing', { humanOnly: true, source: undefined })
expect(detailCtx.status).toBe(404)
expect(detailCtx.body).toEqual({ error: 'Conversation not found' })
})
})