diff --git a/frontend/src/app/router/index.ts b/frontend/src/app/router/index.ts index e78265e..19f8141 100644 --- a/frontend/src/app/router/index.ts +++ b/frontend/src/app/router/index.ts @@ -22,6 +22,11 @@ const routes: RouteRecordRaw[] = [ name: 'documents', component: () => import('../../pages/DocumentsPage.vue'), }, + { + path: '/search', + name: 'search', + component: () => import('../../pages/SearchPage.vue'), + }, { path: '/settings', name: 'settings', diff --git a/frontend/src/features/ingestion/api.ts b/frontend/src/features/ingestion/api.ts index ac749c4..f65480f 100644 --- a/frontend/src/features/ingestion/api.ts +++ b/frontend/src/features/ingestion/api.ts @@ -11,22 +11,6 @@ export interface IngestionStatus { 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 { return apiFetch(`/api/ingestion/${jobId}`, { method: 'POST', @@ -40,13 +24,3 @@ export function deleteIngested(docId: string): Promise { export function fetchIngestionStatus(): Promise { return apiFetch('/api/ingestion/status') } - -export function searchChunks( - query: string, - options: { docId?: string; k?: number } = {}, -): Promise { - 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(`/api/ingestion/search?${params}`) -} diff --git a/frontend/src/features/ingestion/index.ts b/frontend/src/features/ingestion/index.ts index 95916a3..fdedec3 100644 --- a/frontend/src/features/ingestion/index.ts +++ b/frontend/src/features/ingestion/index.ts @@ -1 +1,2 @@ export { useIngestionStore } from './store' +export { default as IngestPanel } from './ui/IngestPanel.vue' diff --git a/frontend/src/features/ingestion/store.ts b/frontend/src/features/ingestion/store.ts index 9b2620b..7ffc01c 100644 --- a/frontend/src/features/ingestion/store.ts +++ b/frontend/src/features/ingestion/store.ts @@ -13,11 +13,6 @@ export const useIngestionStore = defineStore('ingestion', () => { const ingestedDocs = ref>({}) /** Current step of the ingestion pipeline (null when idle) */ const currentStep = ref(null) - /** Search results */ - const searchResults = ref([]) - const searchQuery = ref('') - const searching = ref(false) - let _pollTimer: ReturnType | null = null async function checkAvailability(): Promise { @@ -77,30 +72,6 @@ export const useIngestionStore = defineStore('ingestion', () => { } } - async function search(query: string, docId?: string): Promise { - 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 { available, opensearchConnected, @@ -108,15 +79,10 @@ export const useIngestionStore = defineStore('ingestion', () => { error, ingestedDocs, currentStep, - searchResults, - searchQuery, - searching, checkAvailability, startPolling, stopPolling, ingest, deleteIngested, - search, - clearSearch, } }) diff --git a/frontend/src/features/ingestion/ui/IngestPanel.vue b/frontend/src/features/ingestion/ui/IngestPanel.vue new file mode 100644 index 0000000..d2f55ae --- /dev/null +++ b/frontend/src/features/ingestion/ui/IngestPanel.vue @@ -0,0 +1,346 @@ + + + + + diff --git a/frontend/src/features/search/api.test.ts b/frontend/src/features/search/api.test.ts new file mode 100644 index 0000000..bfe43aa --- /dev/null +++ b/frontend/src/features/search/api.test.ts @@ -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' } }), + ) + }) +}) diff --git a/frontend/src/features/search/api.ts b/frontend/src/features/search/api.ts new file mode 100644 index 0000000..5777972 --- /dev/null +++ b/frontend/src/features/search/api.ts @@ -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 { + 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(`/api/ingestion/search?${params}`) +} diff --git a/frontend/src/features/search/index.ts b/frontend/src/features/search/index.ts new file mode 100644 index 0000000..6b320fb --- /dev/null +++ b/frontend/src/features/search/index.ts @@ -0,0 +1 @@ +export { useSearchStore } from './store' diff --git a/frontend/src/features/search/store.test.ts b/frontend/src/features/search/store.test.ts new file mode 100644 index 0000000..be52b2b --- /dev/null +++ b/frontend/src/features/search/store.test.ts @@ -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) + }) + }) +}) diff --git a/frontend/src/features/search/store.ts b/frontend/src/features/search/store.ts new file mode 100644 index 0000000..2014607 --- /dev/null +++ b/frontend/src/features/search/store.ts @@ -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([]) + const query = ref('') + const searching = ref(false) + + async function search(q: string, docId?: string): Promise { + 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, + } +}) diff --git a/frontend/src/pages/DocumentsPage.vue b/frontend/src/pages/DocumentsPage.vue index a4759e5..cc5727e 100644 --- a/frontend/src/pages/DocumentsPage.vue +++ b/frontend/src/pages/DocumentsPage.vue @@ -30,46 +30,6 @@ - - - - -
-
- - {{ t('ingestion.stepEmbedding') }} -
-
-
- - {{ t('ingestion.stepIndexing') }} -
-
-
- - {{ t('ingestion.stepDone') }}
@@ -507,6 +467,15 @@ @rechunked="onRechunked" />
+ + +
+ +
@@ -522,6 +491,7 @@ import { DocumentUpload, DocumentList } from '../features/document/index' import { ResultTabs } from '../features/analysis/index' import BboxOverlay from '../features/analysis/ui/BboxOverlay.vue' import { ChunkPanel } from '../features/chunking' +import { IngestPanel } from '../features/ingestion' import { useFeatureFlag } from '../features/feature-flags' import { getPreviewUrl } from '../features/document/api' import { useI18n } from '../shared/i18n' @@ -632,11 +602,6 @@ async function runAnalysis() { await analysisStore.run(documentStore.selectedId, { ...pipelineOptions }) } -async function runIngestion() { - if (!analysisStore.currentAnalysis?.id) return - await ingestionStore.ingest(analysisStore.currentAnalysis.id) -} - function addMore() { documentStore.selectedId = null } @@ -658,22 +623,12 @@ watch(currentPage, () => { }) // Auto-switch to verify when analysis completes + refresh document data (pageCount) -// Auto-trigger ingestion if pipeline is available (#81) watch( () => analysisStore.currentAnalysis?.status, - async (status) => { + (status) => { if (status === 'COMPLETED') { mode.value = 'verify' documentStore.load() - - // Auto-ingest if chunks are available and pipeline is configured - if ( - ingestionStore.available && - analysisStore.currentAnalysis?.chunksJson && - analysisStore.currentAnalysis?.id - ) { - await ingestionStore.ingest(analysisStore.currentAnalysis.id) - } } }, ) @@ -895,21 +850,6 @@ onBeforeUnmount(() => { cursor: not-allowed; } -.topbar-btn.ingest { - background: var(--success); - border-color: var(--success); - color: white; -} - -.topbar-btn.ingest:hover:not(:disabled) { - filter: brightness(1.1); -} - -.topbar-btn.ingest:disabled { - opacity: 0.6; - cursor: not-allowed; -} - .topbar-btn .btn-icon { width: 16px; height: 16px; @@ -924,79 +864,6 @@ onBeforeUnmount(() => { animation: spin 0.6s linear infinite; } -/* Ingestion stepper */ -.ingestion-stepper { - display: flex; - align-items: center; - justify-content: center; - gap: 0; - padding: 8px 20px; - background: var(--bg-surface); - border-bottom: 1px solid var(--border); -} - -.step { - display: flex; - align-items: center; - gap: 6px; - padding: 0 8px; -} - -.step-dot { - width: 10px; - height: 10px; - border-radius: 50%; - background: var(--border); - transition: all 0.3s ease; -} - -.step.active .step-dot { - background: var(--accent); - box-shadow: 0 0 6px var(--accent); - animation: pulse-dot 1s ease-in-out infinite; -} - -.step.done .step-dot { - background: var(--success, #22c55e); -} - -.step-label { - font-size: 12px; - color: var(--text-muted); - font-weight: 500; -} - -.step.active .step-label { - color: var(--accent); -} - -.step.done .step-label { - color: var(--success, #22c55e); -} - -.step-line { - width: 40px; - height: 2px; - background: var(--border); - transition: background 0.3s ease; -} - -.step-line.done { - background: var(--success, #22c55e); -} - -@keyframes pulse-dot { - 0%, - 100% { - transform: scale(1); - opacity: 1; - } - 50% { - transform: scale(1.3); - opacity: 0.7; - } -} - /* Doc info bar */ .doc-infobar { display: flex; @@ -1541,9 +1408,10 @@ onBeforeUnmount(() => { padding-top: 16px; } -/* Verify panel */ +/* Verify / Prepare / Ingest panels */ .verify-panel, -.prepare-panel { +.prepare-panel, +.ingest-panel-wrapper { height: 100%; overflow: hidden; display: flex; diff --git a/frontend/src/shared/i18n.ts b/frontend/src/shared/i18n.ts index 3c9dd5f..2c7ff81 100644 --- a/frontend/src/shared/i18n.ts +++ b/frontend/src/shared/i18n.ts @@ -109,6 +109,7 @@ const messages: Messages = { // Chunking 'studio.prepare': 'Préparer', + 'studio.ingest': 'Ingérer', 'chunking.settings': 'Chunking', 'chunking.chunkerType': 'Type de chunker', 'chunking.maxTokens': 'Tokens max', @@ -131,8 +132,15 @@ const messages: Messages = { '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.', + // Search + 'nav.search': 'Recherche', + 'search.hint': 'Saisissez un terme pour rechercher dans les chunks indexés.', + // Ingestion / My Documents 'ingestion.ingest': 'Ingérer', + 'ingestion.document': 'Document', + 'ingestion.chunkCount': 'Chunks prêts', + 'ingestion.successMessage': 'Indexation terminée avec succès !', 'ingestion.ingesting': 'Ingestion...', 'ingestion.reindex': 'Ré-indexer', 'ingestion.indexed': 'Indexé', @@ -270,6 +278,7 @@ const messages: Messages = { 'history.open': 'Open', 'studio.prepare': 'Prepare', + 'studio.ingest': 'Ingest', 'chunking.settings': 'Chunking', 'chunking.chunkerType': 'Chunker type', 'chunking.maxTokens': 'Max tokens', @@ -292,7 +301,13 @@ const messages: Messages = { 'chunking.batchNotice': '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.document': 'Document', + 'ingestion.chunkCount': 'Chunks ready', + 'ingestion.successMessage': 'Indexing completed successfully!', 'ingestion.ingesting': 'Ingesting...', 'ingestion.reindex': 'Re-index', 'ingestion.indexed': 'Indexed', diff --git a/frontend/src/shared/ui/AppSidebar.vue b/frontend/src/shared/ui/AppSidebar.vue index 16ef716..f641d37 100644 --- a/frontend/src/shared/ui/AppSidebar.vue +++ b/frontend/src/shared/ui/AppSidebar.vue @@ -45,6 +45,22 @@ {{ t('nav.documents') }} + + + + + {{ t('nav.search') }} + +