feat(#159): extract search into dedicated sidebar tab

Move chunk search from DocumentsPage into a new Search bounded context
(features/search/) with its own store, API layer, page and route.
Clean search state out of the ingestion module.

Closes #159
This commit is contained in:
Pier-Jean Malandrino 2026-04-11 10:14:02 +02:00
parent 378a6caa78
commit bae00e4025
12 changed files with 430 additions and 187 deletions

View file

@ -22,6 +22,11 @@ const routes: RouteRecordRaw[] = [
name: 'documents', name: 'documents',
component: () => import('../../pages/DocumentsPage.vue'), component: () => import('../../pages/DocumentsPage.vue'),
}, },
{
path: '/search',
name: 'search',
component: () => import('../../pages/SearchPage.vue'),
},
{ {
path: '/settings', path: '/settings',
name: 'settings', name: 'settings',

View file

@ -11,22 +11,6 @@ export interface IngestionStatus {
opensearchConnected: boolean opensearchConnected: boolean
} }
export interface SearchResultItem {
docId: string
filename: string
content: string
chunkIndex: number
pageNumber: number
score: number
headings: string[]
}
export interface SearchResponse {
results: SearchResultItem[]
total: number
query: string
}
export function ingestAnalysis(jobId: string): Promise<IngestionResult> { export function ingestAnalysis(jobId: string): Promise<IngestionResult> {
return apiFetch<IngestionResult>(`/api/ingestion/${jobId}`, { return apiFetch<IngestionResult>(`/api/ingestion/${jobId}`, {
method: 'POST', method: 'POST',
@ -40,13 +24,3 @@ export function deleteIngested(docId: string): Promise<unknown> {
export function fetchIngestionStatus(): Promise<IngestionStatus> { export function fetchIngestionStatus(): Promise<IngestionStatus> {
return apiFetch<IngestionStatus>('/api/ingestion/status') return apiFetch<IngestionStatus>('/api/ingestion/status')
} }
export function searchChunks(
query: string,
options: { docId?: string; k?: number } = {},
): Promise<SearchResponse> {
const params = new URLSearchParams({ q: query })
if (options.docId) params.set('doc_id', options.docId)
if (options.k) params.set('k', String(options.k))
return apiFetch<SearchResponse>(`/api/ingestion/search?${params}`)
}

View file

@ -13,11 +13,6 @@ export const useIngestionStore = defineStore('ingestion', () => {
const ingestedDocs = ref<Record<string, number>>({}) const ingestedDocs = ref<Record<string, number>>({})
/** Current step of the ingestion pipeline (null when idle) */ /** Current step of the ingestion pipeline (null when idle) */
const currentStep = ref<IngestionStep | null>(null) const currentStep = ref<IngestionStep | null>(null)
/** Search results */
const searchResults = ref<api.SearchResultItem[]>([])
const searchQuery = ref('')
const searching = ref(false)
let _pollTimer: ReturnType<typeof setInterval> | null = null let _pollTimer: ReturnType<typeof setInterval> | null = null
async function checkAvailability(): Promise<void> { async function checkAvailability(): Promise<void> {
@ -77,30 +72,6 @@ export const useIngestionStore = defineStore('ingestion', () => {
} }
} }
async function search(query: string, docId?: string): Promise<void> {
if (!query.trim()) {
searchResults.value = []
searchQuery.value = ''
return
}
searching.value = true
searchQuery.value = query
try {
const resp = await api.searchChunks(query, { docId })
searchResults.value = resp.results
} catch (e) {
console.error('Search failed', e)
searchResults.value = []
} finally {
searching.value = false
}
}
function clearSearch(): void {
searchResults.value = []
searchQuery.value = ''
}
return { return {
available, available,
opensearchConnected, opensearchConnected,
@ -108,15 +79,10 @@ export const useIngestionStore = defineStore('ingestion', () => {
error, error,
ingestedDocs, ingestedDocs,
currentStep, currentStep,
searchResults,
searchQuery,
searching,
checkAvailability, checkAvailability,
startPolling, startPolling,
stopPolling, stopPolling,
ingest, ingest,
deleteIngested, deleteIngested,
search,
clearSearch,
} }
}) })

View file

@ -0,0 +1,54 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { searchChunks } from './api'
const mockFetch = vi.fn()
vi.stubGlobal('fetch', mockFetch)
beforeEach(() => {
mockFetch.mockReset()
})
describe('searchChunks', () => {
it('calls /api/ingestion/search with query', async () => {
mockFetch.mockResolvedValue({
ok: true,
status: 200,
json: () =>
Promise.resolve({
results: [
{
docId: 'doc-1',
filename: 'test.pdf',
content: 'hello',
chunkIndex: 0,
pageNumber: 1,
score: 0.95,
headings: [],
},
],
total: 1,
query: 'hello',
}),
})
const result = await searchChunks('hello')
expect(mockFetch).toHaveBeenCalledWith(
'/api/ingestion/search?q=hello',
expect.objectContaining({ headers: { 'Content-Type': 'application/json' } }),
)
expect(result.results).toHaveLength(1)
expect(result.results[0].score).toBe(0.95)
})
it('passes docId and k options', async () => {
mockFetch.mockResolvedValue({
ok: true,
status: 200,
json: () => Promise.resolve({ results: [], total: 0, query: 'test' }),
})
await searchChunks('test', { docId: 'doc-1', k: 5 })
expect(mockFetch).toHaveBeenCalledWith(
'/api/ingestion/search?q=test&doc_id=doc-1&k=5',
expect.objectContaining({ headers: { 'Content-Type': 'application/json' } }),
)
})
})

View file

@ -0,0 +1,27 @@
import { apiFetch } from '../../shared/api/http'
export interface SearchResultItem {
docId: string
filename: string
content: string
chunkIndex: number
pageNumber: number
score: number
headings: string[]
}
export interface SearchResponse {
results: SearchResultItem[]
total: number
query: string
}
export function searchChunks(
query: string,
options: { docId?: string; k?: number } = {},
): Promise<SearchResponse> {
const params = new URLSearchParams({ q: query })
if (options.docId) params.set('doc_id', options.docId)
if (options.k) params.set('k', String(options.k))
return apiFetch<SearchResponse>(`/api/ingestion/search?${params}`)
}

View file

@ -0,0 +1 @@
export { useSearchStore } from './store'

View file

@ -0,0 +1,87 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { setActivePinia, createPinia } from 'pinia'
import { useSearchStore } from './store'
import * as api from './api'
vi.mock('./api', () => ({
searchChunks: vi.fn(),
}))
beforeEach(() => {
setActivePinia(createPinia())
vi.clearAllMocks()
})
describe('useSearchStore', () => {
describe('search', () => {
it('stores results on success', async () => {
vi.mocked(api.searchChunks).mockResolvedValue({
results: [
{
docId: 'doc-1',
filename: 'test.pdf',
content: 'hello world',
chunkIndex: 0,
pageNumber: 1,
score: 0.9,
headings: [],
},
],
total: 1,
query: 'hello',
})
const store = useSearchStore()
await store.search('hello')
expect(store.results).toHaveLength(1)
expect(store.query).toBe('hello')
expect(store.searching).toBe(false)
})
it('clears results on empty query', async () => {
const store = useSearchStore()
store.results = [
{
docId: 'doc-1',
filename: 'test.pdf',
content: 'hello',
chunkIndex: 0,
pageNumber: 1,
score: 0.9,
headings: [],
},
]
await store.search('')
expect(store.results).toHaveLength(0)
expect(store.query).toBe('')
})
it('clears results on error', async () => {
vi.mocked(api.searchChunks).mockRejectedValue(new Error('fail'))
const store = useSearchStore()
await store.search('hello')
expect(store.results).toHaveLength(0)
expect(store.searching).toBe(false)
})
})
describe('clear', () => {
it('resets state', () => {
const store = useSearchStore()
store.query = 'test'
store.results = [
{
docId: 'doc-1',
filename: 'test.pdf',
content: 'hello',
chunkIndex: 0,
pageNumber: 1,
score: 0.9,
headings: [],
},
]
store.clear()
expect(store.query).toBe('')
expect(store.results).toHaveLength(0)
})
})
})

View file

@ -0,0 +1,41 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import * as api from './api'
export const useSearchStore = defineStore('search', () => {
const results = ref<api.SearchResultItem[]>([])
const query = ref('')
const searching = ref(false)
async function search(q: string, docId?: string): Promise<void> {
if (!q.trim()) {
results.value = []
query.value = ''
return
}
searching.value = true
query.value = q
try {
const resp = await api.searchChunks(q, { docId })
results.value = resp.results
} catch (e) {
console.error('Search failed', e)
results.value = []
} finally {
searching.value = false
}
}
function clear(): void {
results.value = []
query.value = ''
}
return {
results,
query,
searching,
search,
clear,
}
})

View file

@ -30,46 +30,6 @@
</div> </div>
</div> </div>
</div> </div>
<!-- Full-text chunk search -->
<div v-if="ingestionStore.available" class="chunk-search-bar">
<input
v-model="chunkSearchQuery"
type="text"
class="search-input chunk-search"
:placeholder="t('ingestion.searchChunks')"
@keyup.enter="runChunkSearch"
/>
<div v-if="ingestionStore.searching" class="spinner-xs" />
</div>
<div v-if="ingestionStore.searchResults.length > 0" class="search-results">
<div
v-for="(result, idx) in ingestionStore.searchResults"
:key="idx"
class="search-result-item"
>
<div class="result-header">
<span class="result-filename">{{ result.filename }}</span>
<span class="result-meta"
>p.{{ result.pageNumber }} chunk #{{ result.chunkIndex }}</span
>
<span class="result-score">{{ (result.score * 100).toFixed(0) }}%</span>
</div>
<p class="result-content">
{{ result.content.slice(0, 200) }}{{ result.content.length > 200 ? '…' : '' }}
</p>
</div>
</div>
<div
v-if="
ingestionStore.searchQuery &&
!ingestionStore.searching &&
ingestionStore.searchResults.length === 0
"
class="tab-empty"
>
{{ t('ingestion.noResults', { q: ingestionStore.searchQuery }) }}
</div>
<div class="page-content"> <div class="page-content">
<div v-if="filteredDocs.length === 0" class="tab-empty"> <div v-if="filteredDocs.length === 0" class="tab-empty">
{{ t('history.emptyDocs') }} {{ t('history.emptyDocs') }}
@ -161,7 +121,6 @@ const router = useRouter()
const { t } = useI18n() const { t } = useI18n()
const searchQuery = ref('') const searchQuery = ref('')
const chunkSearchQuery = ref('')
const activeFilter = ref<'all' | 'indexed' | 'not-indexed'>('all') const activeFilter = ref<'all' | 'indexed' | 'not-indexed'>('all')
const sortBy = ref<'name' | 'date'>('date') const sortBy = ref<'name' | 'date'>('date')
@ -220,14 +179,6 @@ async function handleDelete(docId: string) {
await docStore.remove(docId) await docStore.remove(docId)
} }
function runChunkSearch() {
if (chunkSearchQuery.value.trim()) {
ingestionStore.search(chunkSearchQuery.value)
} else {
ingestionStore.clearSearch()
}
}
onMounted(() => { onMounted(() => {
docStore.load() docStore.load()
ingestionStore.checkAvailability() ingestionStore.checkAvailability()
@ -451,82 +402,4 @@ onMounted(() => {
width: 16px; width: 16px;
height: 16px; height: 16px;
} }
/* Chunk search */
.chunk-search-bar {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 24px;
border-bottom: 1px solid var(--border);
}
.chunk-search {
flex: 1;
}
.spinner-xs {
width: 14px;
height: 14px;
border: 2px solid var(--border);
border-top-color: var(--accent);
border-radius: 50%;
animation: spin 0.6s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
/* Search results */
.search-results {
max-height: 300px;
overflow-y: auto;
border-bottom: 1px solid var(--border);
}
.search-result-item {
padding: 10px 24px;
border-bottom: 1px solid var(--border);
}
.search-result-item:last-child {
border-bottom: none;
}
.result-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 4px;
}
.result-filename {
font-weight: 600;
font-size: 13px;
color: var(--text);
}
.result-meta {
font-size: 11px;
color: var(--text-muted);
font-family: 'IBM Plex Mono', monospace;
}
.result-score {
margin-left: auto;
font-size: 11px;
font-weight: 600;
color: var(--accent);
font-family: 'IBM Plex Mono', monospace;
}
.result-content {
font-size: 13px;
color: var(--text-muted);
line-height: 1.5;
margin: 0;
}
</style> </style>

View file

@ -0,0 +1,192 @@
<template>
<div class="search-page">
<div class="page-header">
<h1 class="page-title">{{ t('nav.search') }}</h1>
</div>
<div v-if="!ingestionStore.available" class="tab-empty">
{{ t('ingestion.unavailable') }}
</div>
<template v-else>
<div class="chunk-search-bar">
<input
v-model="searchInput"
type="text"
class="search-input"
:placeholder="t('ingestion.searchChunks')"
@keyup.enter="runSearch"
/>
<div v-if="searchStore.searching" class="spinner-xs" />
</div>
<div v-if="searchStore.results.length > 0" class="search-results">
<div v-for="(result, idx) in searchStore.results" :key="idx" class="search-result-item">
<div class="result-header">
<span class="result-filename">{{ result.filename }}</span>
<span class="result-meta"
>p.{{ result.pageNumber }} chunk #{{ result.chunkIndex }}</span
>
<span class="result-score">{{ (result.score * 100).toFixed(0) }}%</span>
</div>
<p class="result-content">
{{ result.content.slice(0, 200) }}{{ result.content.length > 200 ? '…' : '' }}
</p>
</div>
</div>
<div
v-if="searchStore.query && !searchStore.searching && searchStore.results.length === 0"
class="tab-empty"
>
{{ t('ingestion.noResults', { q: searchStore.query }) }}
</div>
<div v-if="!searchStore.query" class="tab-empty">
{{ t('search.hint') }}
</div>
</template>
</div>
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { useSearchStore } from '../features/search/store'
import { useIngestionStore } from '../features/ingestion/store'
import { useI18n } from '../shared/i18n'
const searchStore = useSearchStore()
const ingestionStore = useIngestionStore()
const { t } = useI18n()
const searchInput = ref('')
function runSearch() {
if (searchInput.value.trim()) {
searchStore.search(searchInput.value)
} else {
searchStore.clear()
}
}
onMounted(() => {
ingestionStore.checkAvailability()
})
</script>
<style scoped>
.search-page {
display: flex;
flex-direction: column;
height: 100%;
overflow: hidden;
}
.page-header {
padding: 16px 24px;
border-bottom: 1px solid var(--border);
flex-shrink: 0;
}
.page-title {
font-size: 18px;
font-weight: 600;
color: var(--text);
}
.tab-empty {
text-align: center;
color: var(--text-muted);
padding: 60px 20px;
font-size: 14px;
}
/* Chunk search */
.chunk-search-bar {
display: flex;
align-items: center;
gap: 8px;
padding: 12px 24px;
border-bottom: 1px solid var(--border);
}
.search-input {
flex: 1;
padding: 8px 12px;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: var(--bg);
color: var(--text);
font-size: 13px;
outline: none;
transition: border-color var(--transition);
}
.search-input:focus {
border-color: var(--accent);
}
.spinner-xs {
width: 14px;
height: 14px;
border: 2px solid var(--border);
border-top-color: var(--accent);
border-radius: 50%;
animation: spin 0.6s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
/* Search results */
.search-results {
flex: 1;
overflow-y: auto;
}
.search-result-item {
padding: 10px 24px;
border-bottom: 1px solid var(--border);
}
.search-result-item:last-child {
border-bottom: none;
}
.result-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 4px;
}
.result-filename {
font-weight: 600;
font-size: 13px;
color: var(--text);
}
.result-meta {
font-size: 11px;
color: var(--text-muted);
font-family: 'IBM Plex Mono', monospace;
}
.result-score {
margin-left: auto;
font-size: 11px;
font-weight: 600;
color: var(--accent);
font-family: 'IBM Plex Mono', monospace;
}
.result-content {
font-size: 13px;
color: var(--text-muted);
line-height: 1.5;
margin: 0;
}
</style>

View file

@ -131,6 +131,10 @@ const messages: Messages = {
'chunking.batchNotice': 'chunking.batchNotice':
'Le chunking n\u2019est pas disponible pour cette analyse. Les documents volumineux trait\u00e9s par batch ne g\u00e9n\u00e8rent pas la structure interne n\u00e9cessaire au d\u00e9coupage.', 'Le chunking n\u2019est pas disponible pour cette analyse. Les documents volumineux trait\u00e9s par batch ne g\u00e9n\u00e8rent pas la structure interne n\u00e9cessaire au d\u00e9coupage.',
// Search
'nav.search': 'Recherche',
'search.hint': 'Saisissez un terme pour rechercher dans les chunks indexés.',
// Ingestion / My Documents // Ingestion / My Documents
'ingestion.ingest': 'Ingérer', 'ingestion.ingest': 'Ingérer',
'ingestion.ingesting': 'Ingestion...', 'ingestion.ingesting': 'Ingestion...',
@ -292,6 +296,9 @@ const messages: Messages = {
'chunking.batchNotice': 'chunking.batchNotice':
'Chunking is not available for this analysis. Large documents processed in batch mode do not generate the internal structure required for chunking.', 'Chunking is not available for this analysis. Large documents processed in batch mode do not generate the internal structure required for chunking.',
'nav.search': 'Search',
'search.hint': 'Enter a term to search through indexed chunks.',
'ingestion.ingest': 'Ingest', 'ingestion.ingest': 'Ingest',
'ingestion.ingesting': 'Ingesting...', 'ingestion.ingesting': 'Ingesting...',
'ingestion.reindex': 'Re-index', 'ingestion.reindex': 'Re-index',

View file

@ -45,6 +45,22 @@
<span class="nav-label">{{ t('nav.documents') }}</span> <span class="nav-label">{{ t('nav.documents') }}</span>
</RouterLink> </RouterLink>
<RouterLink
to="/search"
class="nav-item"
data-e2e="nav-search"
:class="{ active: route.name === 'search' }"
>
<svg class="nav-icon" viewBox="0 0 20 20" fill="currentColor">
<path
fill-rule="evenodd"
d="M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z"
clip-rule="evenodd"
/>
</svg>
<span class="nav-label">{{ t('nav.search') }}</span>
</RouterLink>
<RouterLink <RouterLink
to="/history" to="/history"
class="nav-item" class="nav-item"