diff --git a/frontend/src/features/ingestion/api.test.ts b/frontend/src/features/ingestion/api.test.ts new file mode 100644 index 0000000..1035474 --- /dev/null +++ b/frontend/src/features/ingestion/api.test.ts @@ -0,0 +1,48 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { ingestAnalysis, deleteIngested, fetchIngestionStatus } from './api' + +const mockFetch = vi.fn() +vi.stubGlobal('fetch', mockFetch) + +beforeEach(() => { + mockFetch.mockReset() +}) + +describe('ingestAnalysis', () => { + it('posts to /api/ingestion/:jobId', async () => { + mockFetch.mockResolvedValue({ + ok: true, + status: 200, + json: () => Promise.resolve({ docId: 'doc-1', chunksIndexed: 5, embeddingDimension: 384 }), + }) + const result = await ingestAnalysis('job-1') + expect(mockFetch).toHaveBeenCalledWith( + '/api/ingestion/job-1', + expect.objectContaining({ method: 'POST' }), + ) + expect(result.chunksIndexed).toBe(5) + }) +}) + +describe('deleteIngested', () => { + it('deletes /api/ingestion/:docId', async () => { + mockFetch.mockResolvedValue({ ok: true, status: 204, json: () => Promise.resolve(null) }) + await deleteIngested('doc-1') + expect(mockFetch).toHaveBeenCalledWith( + '/api/ingestion/doc-1', + expect.objectContaining({ method: 'DELETE' }), + ) + }) +}) + +describe('fetchIngestionStatus', () => { + it('gets /api/ingestion/status', async () => { + mockFetch.mockResolvedValue({ + ok: true, + status: 200, + json: () => Promise.resolve({ available: true }), + }) + const result = await fetchIngestionStatus() + expect(result.available).toBe(true) + }) +}) diff --git a/frontend/src/features/ingestion/api.ts b/frontend/src/features/ingestion/api.ts new file mode 100644 index 0000000..4cdb345 --- /dev/null +++ b/frontend/src/features/ingestion/api.ts @@ -0,0 +1,25 @@ +import { apiFetch } from '../../shared/api/http' + +export interface IngestionResult { + docId: string + chunksIndexed: number + embeddingDimension: number +} + +export interface IngestionStatus { + available: boolean +} + +export function ingestAnalysis(jobId: string): Promise { + return apiFetch(`/api/ingestion/${jobId}`, { + method: 'POST', + }) +} + +export function deleteIngested(docId: string): Promise { + return apiFetch(`/api/ingestion/${docId}`, { method: 'DELETE' }) +} + +export function fetchIngestionStatus(): Promise { + return apiFetch('/api/ingestion/status') +} diff --git a/frontend/src/features/ingestion/index.ts b/frontend/src/features/ingestion/index.ts new file mode 100644 index 0000000..95916a3 --- /dev/null +++ b/frontend/src/features/ingestion/index.ts @@ -0,0 +1 @@ +export { useIngestionStore } from './store' diff --git a/frontend/src/features/ingestion/store.test.ts b/frontend/src/features/ingestion/store.test.ts new file mode 100644 index 0000000..b911903 --- /dev/null +++ b/frontend/src/features/ingestion/store.test.ts @@ -0,0 +1,66 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { setActivePinia, createPinia } from 'pinia' +import { useIngestionStore } from './store' +import * as api from './api' + +vi.mock('./api', () => ({ + fetchIngestionStatus: vi.fn(), + ingestAnalysis: vi.fn(), + deleteIngested: vi.fn(), +})) + +beforeEach(() => { + setActivePinia(createPinia()) + vi.clearAllMocks() +}) + +describe('useIngestionStore', () => { + describe('checkAvailability', () => { + it('sets available to true when API responds', async () => { + vi.mocked(api.fetchIngestionStatus).mockResolvedValue({ available: true }) + const store = useIngestionStore() + await store.checkAvailability() + expect(store.available).toBe(true) + }) + + it('sets available to false on error', async () => { + vi.mocked(api.fetchIngestionStatus).mockRejectedValue(new Error('fail')) + const store = useIngestionStore() + await store.checkAvailability() + expect(store.available).toBe(false) + }) + }) + + describe('ingest', () => { + it('calls API and tracks ingested doc', async () => { + vi.mocked(api.ingestAnalysis).mockResolvedValue({ + docId: 'doc-1', + chunksIndexed: 5, + embeddingDimension: 384, + }) + const store = useIngestionStore() + const result = await store.ingest('job-1') + expect(result?.chunksIndexed).toBe(5) + expect(store.ingestedDocs['doc-1']).toBe(5) + expect(store.ingesting).toBe(false) + }) + + it('sets error on failure', async () => { + vi.mocked(api.ingestAnalysis).mockRejectedValue(new Error('fail')) + const store = useIngestionStore() + const result = await store.ingest('job-1') + expect(result).toBeNull() + expect(store.error).toBe('fail') + }) + }) + + describe('deleteIngested', () => { + it('removes doc from tracked map', async () => { + vi.mocked(api.deleteIngested).mockResolvedValue(null) + const store = useIngestionStore() + store.ingestedDocs['doc-1'] = 5 + await store.deleteIngested('doc-1') + expect(store.ingestedDocs['doc-1']).toBeUndefined() + }) + }) +}) diff --git a/frontend/src/features/ingestion/store.ts b/frontend/src/features/ingestion/store.ts new file mode 100644 index 0000000..4fbcd14 --- /dev/null +++ b/frontend/src/features/ingestion/store.ts @@ -0,0 +1,56 @@ +import { defineStore } from 'pinia' +import { ref } from 'vue' +import * as api from './api' + +export const useIngestionStore = defineStore('ingestion', () => { + const available = ref(false) + const ingesting = ref(false) + const error = ref(null) + /** Map of docId → chunks indexed count (tracks which docs are ingested) */ + const ingestedDocs = ref>({}) + + async function checkAvailability(): Promise { + try { + const status = await api.fetchIngestionStatus() + available.value = status.available + } catch { + available.value = false + } + } + + async function ingest(jobId: string): Promise { + ingesting.value = true + error.value = null + try { + const result = await api.ingestAnalysis(jobId) + ingestedDocs.value[result.docId] = result.chunksIndexed + return result + } catch (e) { + error.value = (e as Error).message || 'Ingestion failed' + console.error('Ingestion failed', e) + return null + } finally { + ingesting.value = false + } + } + + async function deleteIngested(docId: string): Promise { + try { + await api.deleteIngested(docId) + delete ingestedDocs.value[docId] + } catch (e) { + error.value = (e as Error).message || 'Failed to delete ingested data' + console.error('Failed to delete ingested data', e) + } + } + + return { + available, + ingesting, + error, + ingestedDocs, + checkAvailability, + ingest, + deleteIngested, + } +}) diff --git a/frontend/src/pages/DocumentsPage.vue b/frontend/src/pages/DocumentsPage.vue index 9dd2351..3256bff 100644 --- a/frontend/src/pages/DocumentsPage.vue +++ b/frontend/src/pages/DocumentsPage.vue @@ -2,13 +2,40 @@
-
+
{{ t('history.emptyDocs') }}
-
+
- +
+ + {{ t('ingestion.indexed') }} + {{ ingestionStore.ingestedDocs[doc.id] }} + + + {{ t('ingestion.notIndexed') }} + + + +
@@ -42,21 +93,75 @@ @@ -72,6 +177,11 @@ onMounted(() => { padding: 16px 24px; border-bottom: 1px solid var(--border); flex-shrink: 0; + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + flex-wrap: wrap; } .page-title { @@ -80,6 +190,57 @@ onMounted(() => { color: var(--text); } +.header-actions { + display: flex; + align-items: center; + gap: 12px; +} + +.search-input { + padding: 6px 12px; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + background: var(--bg); + color: var(--text); + font-size: 13px; + width: 180px; + outline: none; + transition: border-color var(--transition); +} + +.search-input:focus { + border-color: var(--accent); +} + +.filter-group, +.sort-group { + display: flex; + gap: 2px; + background: var(--bg-surface); + border-radius: var(--radius-sm); + padding: 2px; + border: 1px solid var(--border); +} + +.filter-btn, +.sort-btn { + padding: 4px 10px; + border: none; + background: none; + color: var(--text-secondary); + font-size: 12px; + font-weight: 500; + border-radius: 4px; + cursor: pointer; + transition: all var(--transition); +} + +.filter-btn.active, +.sort-btn.active { + background: var(--accent); + color: white; +} + .page-content { flex: 1; overflow-y: auto; @@ -152,7 +313,41 @@ onMounted(() => { font-family: 'IBM Plex Mono', monospace; } -.doc-row-delete { +.doc-row-actions { + display: flex; + align-items: center; + gap: 8px; + flex-shrink: 0; +} + +.status-badge { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 3px 8px; + border-radius: 10px; + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.03em; +} + +.status-badge.indexed { + background: rgba(34, 197, 94, 0.15); + color: var(--success); +} + +.status-badge.not-indexed { + background: rgba(156, 163, 175, 0.15); + color: var(--text-muted); +} + +.badge-count { + font-family: 'IBM Plex Mono', monospace; + font-size: 10px; +} + +.action-btn { background: none; border: none; padding: 6px; @@ -164,14 +359,21 @@ onMounted(() => { transition: all var(--transition); } -.doc-row:hover .doc-row-delete { +.doc-row:hover .action-btn { opacity: 1; } -.doc-row-delete:hover { + +.action-btn:hover { + color: var(--accent); + background: rgba(249, 115, 22, 0.1); +} + +.action-btn.delete:hover { color: var(--error); background: rgba(239, 68, 68, 0.1); } -.doc-row-delete svg { + +.action-btn svg { width: 16px; height: 16px; } diff --git a/frontend/src/shared/i18n.ts b/frontend/src/shared/i18n.ts index d56832e..8a20d57 100644 --- a/frontend/src/shared/i18n.ts +++ b/frontend/src/shared/i18n.ts @@ -131,6 +131,23 @@ 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.', + // Ingestion / My Documents + 'ingestion.ingest': 'Ingérer', + 'ingestion.ingesting': 'Ingestion...', + 'ingestion.reindex': 'Ré-indexer', + 'ingestion.indexed': 'Indexé', + 'ingestion.notIndexed': 'Non indexé', + 'ingestion.chunksIndexed': '{n} chunks indexés', + 'ingestion.openInStudio': 'Ouvrir dans le Studio', + 'ingestion.deleteIndex': "Supprimer de l'index", + 'ingestion.unavailable': 'Ingestion non disponible', + 'ingestion.filterAll': 'Tous', + 'ingestion.filterIndexed': 'Indexés', + 'ingestion.filterNotIndexed': 'Non indexés', + 'ingestion.sortName': 'Nom', + 'ingestion.sortDate': 'Date', + 'ingestion.search': 'Rechercher...', + // Pagination 'pagination.pageOf': 'Page {current} sur {total}', 'pagination.perPage': '/ page', @@ -266,6 +283,22 @@ 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.', + 'ingestion.ingest': 'Ingest', + 'ingestion.ingesting': 'Ingesting...', + 'ingestion.reindex': 'Re-index', + 'ingestion.indexed': 'Indexed', + 'ingestion.notIndexed': 'Not indexed', + 'ingestion.chunksIndexed': '{n} chunks indexed', + 'ingestion.openInStudio': 'Open in Studio', + 'ingestion.deleteIndex': 'Remove from index', + 'ingestion.unavailable': 'Ingestion unavailable', + 'ingestion.filterAll': 'All', + 'ingestion.filterIndexed': 'Indexed', + 'ingestion.filterNotIndexed': 'Not indexed', + 'ingestion.sortName': 'Name', + 'ingestion.sortDate': 'Date', + 'ingestion.search': 'Search...', + 'pagination.pageOf': 'Page {current} of {total}', 'pagination.perPage': '/ page',