Merge pull request #162 from scub-france/feature/ingestion-pipeline

feat: Search tab + Ingest mode in Studio pipeline
This commit is contained in:
Pier-Jean Malandrino 2026-04-11 11:01:30 +02:00 committed by GitHub
commit eb8b3d24a5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 817 additions and 351 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

@ -1 +1,2 @@
export { useIngestionStore } from './store' export { useIngestionStore } from './store'
export { default as IngestPanel } from './ui/IngestPanel.vue'

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,346 @@
<template>
<div class="ingest-panel" data-e2e="ingest-panel">
<!-- Unavailable state -->
<div v-if="!ingestionStore.available" class="ingest-empty">
<svg class="empty-icon" viewBox="0 0 20 20" fill="currentColor">
<path
fill-rule="evenodd"
d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7 4a1 1 0 11-2 0 1 1 0 012 0zm-1-9a1 1 0 00-1 1v4a1 1 0 102 0V6a1 1 0 00-1-1z"
clip-rule="evenodd"
/>
</svg>
<p class="empty-text">{{ t('ingestion.unavailable') }}</p>
</div>
<!-- Ready to ingest -->
<template v-else>
<!-- Summary -->
<div class="ingest-summary">
<div class="summary-row">
<span class="summary-label">{{ t('ingestion.document') }}</span>
<span class="summary-value">{{ documentName }}</span>
</div>
<div class="summary-row">
<span class="summary-label">{{ t('ingestion.chunkCount') }}</span>
<span class="summary-value summary-mono">{{ chunkCount }}</span>
</div>
</div>
<!-- Stepper -->
<div v-if="ingestionStore.currentStep" class="ingestion-stepper">
<div
class="step"
:class="{
active: ingestionStore.currentStep === 'embedding',
done:
ingestionStore.currentStep === 'indexing' || ingestionStore.currentStep === 'done',
}"
>
<span class="step-dot" />
<span class="step-label">{{ t('ingestion.stepEmbedding') }}</span>
</div>
<div
class="step-line"
:class="{
done:
ingestionStore.currentStep === 'indexing' || ingestionStore.currentStep === 'done',
}"
/>
<div
class="step"
:class="{
active: ingestionStore.currentStep === 'indexing',
done: ingestionStore.currentStep === 'done',
}"
>
<span class="step-dot" />
<span class="step-label">{{ t('ingestion.stepIndexing') }}</span>
</div>
<div class="step-line" :class="{ done: ingestionStore.currentStep === 'done' }" />
<div
class="step"
:class="{
active: ingestionStore.currentStep === 'done',
done: ingestionStore.currentStep === 'done',
}"
>
<span class="step-dot" />
<span class="step-label">{{ t('ingestion.stepDone') }}</span>
</div>
</div>
<!-- Error -->
<div v-if="ingestionStore.error" class="ingest-error">
{{ ingestionStore.error }}
</div>
<!-- Success -->
<div v-if="ingestionStore.currentStep === 'done'" class="ingest-success">
<svg class="success-icon" viewBox="0 0 20 20" fill="currentColor">
<path
fill-rule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z"
clip-rule="evenodd"
/>
</svg>
<span>{{ t('ingestion.successMessage') }}</span>
</div>
<!-- Action -->
<button
class="ingest-btn"
data-e2e="ingest-btn"
:disabled="ingestionStore.ingesting || !analysisId"
@click="runIngestion"
>
<div v-if="ingestionStore.ingesting" class="spinner-sm" />
<svg v-else viewBox="0 0 20 20" fill="currentColor" class="btn-icon">
<path
fill-rule="evenodd"
d="M3 17a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zM6.293 6.707a1 1 0 010-1.414l3-3a1 1 0 011.414 0l3 3a1 1 0 01-1.414 1.414L11 5.414V13a1 1 0 11-2 0V5.414L7.707 6.707a1 1 0 01-1.414 0z"
clip-rule="evenodd"
/>
</svg>
{{ ingestionStore.ingesting ? t('ingestion.ingesting') : t('ingestion.ingest') }}
</button>
</template>
</div>
</template>
<script setup lang="ts">
import { useIngestionStore } from '../store'
import { useI18n } from '../../../shared/i18n'
const props = defineProps<{
analysisId: string | null
documentName: string
chunkCount: number
}>()
const ingestionStore = useIngestionStore()
const { t } = useI18n()
async function runIngestion() {
if (!props.analysisId) return
await ingestionStore.ingest(props.analysisId)
}
</script>
<style scoped>
.ingest-panel {
padding: 20px;
display: flex;
flex-direction: column;
gap: 20px;
height: 100%;
}
.ingest-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
padding: 40px 20px;
flex: 1;
}
.empty-icon {
width: 32px;
height: 32px;
color: var(--text-muted);
opacity: 0.5;
}
.empty-text {
font-size: 14px;
color: var(--text-muted);
text-align: center;
}
/* Summary */
.ingest-summary {
display: flex;
flex-direction: column;
gap: 8px;
padding: 12px 16px;
background: var(--bg-surface);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
}
.summary-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.summary-label {
font-size: 12px;
color: var(--text-muted);
font-weight: 500;
}
.summary-value {
font-size: 13px;
color: var(--text);
font-weight: 500;
text-align: right;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
min-width: 0;
}
.summary-mono {
font-family: 'IBM Plex Mono', monospace;
}
/* Stepper */
.ingestion-stepper {
display: flex;
align-items: center;
justify-content: center;
gap: 0;
padding: 12px 0;
}
.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;
}
}
/* Error */
.ingest-error {
padding: 10px 14px;
background: rgba(239, 68, 68, 0.1);
border: 1px solid rgba(239, 68, 68, 0.3);
border-radius: var(--radius-sm);
color: var(--error);
font-size: 13px;
}
/* Success */
.ingest-success {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 14px;
background: rgba(34, 197, 94, 0.1);
border: 1px solid rgba(34, 197, 94, 0.3);
border-radius: var(--radius-sm);
color: var(--success, #22c55e);
font-size: 13px;
font-weight: 500;
}
.success-icon {
width: 18px;
height: 18px;
flex-shrink: 0;
}
/* Action button */
.ingest-btn {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 10px 20px;
font-size: 14px;
font-weight: 600;
color: white;
background: var(--success, #22c55e);
border: none;
border-radius: var(--radius-sm);
cursor: pointer;
transition: all var(--transition);
}
.ingest-btn:hover:not(:disabled) {
filter: brightness(1.1);
}
.ingest-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.ingest-btn .btn-icon {
width: 16px;
height: 16px;
}
.spinner-sm {
width: 14px;
height: 14px;
border: 2px solid rgba(255, 255, 255, 0.3);
border-top-color: white;
border-radius: 50%;
animation: spin 0.6s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
</style>

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.toFixed(1) }}</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

@ -66,6 +66,24 @@
</svg> </svg>
{{ t('studio.prepare') }} {{ t('studio.prepare') }}
</button> </button>
<button
v-if="chunkingEnabled && ingestionStore.available"
class="toggle-btn"
data-e2e="toggle-btn"
:class="{ active: mode === 'ingest' }"
@click="mode = 'ingest'"
:disabled="!canIngest"
:title="!canIngest ? t('ingestion.unavailable') : ''"
>
<svg class="toggle-icon" viewBox="0 0 20 20" fill="currentColor">
<path
fill-rule="evenodd"
d="M3 17a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zM6.293 6.707a1 1 0 010-1.414l3-3a1 1 0 011.414 0l3 3a1 1 0 01-1.414 1.414L11 5.414V13a1 1 0 11-2 0V5.414L7.707 6.707a1 1 0 01-1.414 0z"
clip-rule="evenodd"
/>
</svg>
{{ t('studio.ingest') }}
</button>
</div> </div>
</div> </div>
<div class="topbar-actions"> <div class="topbar-actions">
@ -94,64 +112,6 @@
</svg> </svg>
{{ analysisStore.running ? t('studio.analyzing') : t('studio.run') }} {{ analysisStore.running ? t('studio.analyzing') : t('studio.run') }}
</button> </button>
<button
v-if="canIngest"
class="topbar-btn ingest"
data-e2e="ingest-btn"
:disabled="ingestionStore.ingesting"
@click="runIngestion"
>
<div v-if="ingestionStore.ingesting" class="spinner-sm" />
<svg v-else viewBox="0 0 20 20" fill="currentColor" class="btn-icon">
<path
fill-rule="evenodd"
d="M3 17a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zM6.293 6.707a1 1 0 010-1.414l3-3a1 1 0 011.414 0l3 3a1 1 0 01-1.414 1.414L11 5.414V13a1 1 0 11-2 0V5.414L7.707 6.707a1 1 0 01-1.414 0z"
clip-rule="evenodd"
/>
</svg>
{{ ingestionStore.ingesting ? t('ingestion.ingesting') : t('ingestion.ingest') }}
</button>
</div>
</div>
<!-- Ingestion progress stepper -->
<div v-if="ingestionStore.currentStep" class="ingestion-stepper">
<div
class="step"
:class="{
active: ingestionStore.currentStep === 'embedding',
done: ingestionStore.currentStep === 'indexing' || ingestionStore.currentStep === 'done',
}"
>
<span class="step-dot" />
<span class="step-label">{{ t('ingestion.stepEmbedding') }}</span>
</div>
<div
class="step-line"
:class="{
done: ingestionStore.currentStep === 'indexing' || ingestionStore.currentStep === 'done',
}"
/>
<div
class="step"
:class="{
active: ingestionStore.currentStep === 'indexing',
done: ingestionStore.currentStep === 'done',
}"
>
<span class="step-dot" />
<span class="step-label">{{ t('ingestion.stepIndexing') }}</span>
</div>
<div class="step-line" :class="{ done: ingestionStore.currentStep === 'done' }" />
<div
class="step"
:class="{
active: ingestionStore.currentStep === 'done',
done: ingestionStore.currentStep === 'done',
}"
>
<span class="step-dot" />
<span class="step-label">{{ t('ingestion.stepDone') }}</span>
</div> </div>
</div> </div>
@ -507,6 +467,15 @@
@rechunked="onRechunked" @rechunked="onRechunked"
/> />
</div> </div>
<!-- INGEST MODE -->
<div v-if="mode === 'ingest'" class="ingest-panel-wrapper">
<IngestPanel
:analysis-id="analysisStore.currentAnalysis?.id ?? null"
:document-name="selectedDoc?.filename ?? ''"
:chunk-count="analysisStore.currentChunks?.length ?? 0"
/>
</div>
</div> </div>
</div> </div>
</div> </div>
@ -522,6 +491,7 @@ import { DocumentUpload, DocumentList } from '../features/document/index'
import { ResultTabs } from '../features/analysis/index' import { ResultTabs } from '../features/analysis/index'
import BboxOverlay from '../features/analysis/ui/BboxOverlay.vue' import BboxOverlay from '../features/analysis/ui/BboxOverlay.vue'
import { ChunkPanel } from '../features/chunking' import { ChunkPanel } from '../features/chunking'
import { IngestPanel } from '../features/ingestion'
import { useFeatureFlag } from '../features/feature-flags' import { useFeatureFlag } from '../features/feature-flags'
import { getPreviewUrl } from '../features/document/api' import { getPreviewUrl } from '../features/document/api'
import { useI18n } from '../shared/i18n' import { useI18n } from '../shared/i18n'
@ -632,11 +602,6 @@ async function runAnalysis() {
await analysisStore.run(documentStore.selectedId, { ...pipelineOptions }) await analysisStore.run(documentStore.selectedId, { ...pipelineOptions })
} }
async function runIngestion() {
if (!analysisStore.currentAnalysis?.id) return
await ingestionStore.ingest(analysisStore.currentAnalysis.id)
}
function addMore() { function addMore() {
documentStore.selectedId = null documentStore.selectedId = null
} }
@ -658,22 +623,12 @@ watch(currentPage, () => {
}) })
// Auto-switch to verify when analysis completes + refresh document data (pageCount) // Auto-switch to verify when analysis completes + refresh document data (pageCount)
// Auto-trigger ingestion if pipeline is available (#81)
watch( watch(
() => analysisStore.currentAnalysis?.status, () => analysisStore.currentAnalysis?.status,
async (status) => { (status) => {
if (status === 'COMPLETED') { if (status === 'COMPLETED') {
mode.value = 'verify' mode.value = 'verify'
documentStore.load() 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; 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 { .topbar-btn .btn-icon {
width: 16px; width: 16px;
height: 16px; height: 16px;
@ -924,79 +864,6 @@ onBeforeUnmount(() => {
animation: spin 0.6s linear infinite; 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 info bar */
.doc-infobar { .doc-infobar {
display: flex; display: flex;
@ -1541,9 +1408,10 @@ onBeforeUnmount(() => {
padding-top: 16px; padding-top: 16px;
} }
/* Verify panel */ /* Verify / Prepare / Ingest panels */
.verify-panel, .verify-panel,
.prepare-panel { .prepare-panel,
.ingest-panel-wrapper {
height: 100%; height: 100%;
overflow: hidden; overflow: hidden;
display: flex; display: flex;

View file

@ -109,6 +109,7 @@ const messages: Messages = {
// Chunking // Chunking
'studio.prepare': 'Préparer', 'studio.prepare': 'Préparer',
'studio.ingest': 'Ingérer',
'chunking.settings': 'Chunking', 'chunking.settings': 'Chunking',
'chunking.chunkerType': 'Type de chunker', 'chunking.chunkerType': 'Type de chunker',
'chunking.maxTokens': 'Tokens max', 'chunking.maxTokens': 'Tokens max',
@ -131,8 +132,15 @@ 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.document': 'Document',
'ingestion.chunkCount': 'Chunks prêts',
'ingestion.successMessage': 'Indexation terminée avec succès !',
'ingestion.ingesting': 'Ingestion...', 'ingestion.ingesting': 'Ingestion...',
'ingestion.reindex': 'Ré-indexer', 'ingestion.reindex': 'Ré-indexer',
'ingestion.indexed': 'Indexé', 'ingestion.indexed': 'Indexé',
@ -270,6 +278,7 @@ const messages: Messages = {
'history.open': 'Open', 'history.open': 'Open',
'studio.prepare': 'Prepare', 'studio.prepare': 'Prepare',
'studio.ingest': 'Ingest',
'chunking.settings': 'Chunking', 'chunking.settings': 'Chunking',
'chunking.chunkerType': 'Chunker type', 'chunking.chunkerType': 'Chunker type',
'chunking.maxTokens': 'Max tokens', 'chunking.maxTokens': 'Max tokens',
@ -292,7 +301,13 @@ 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.document': 'Document',
'ingestion.chunkCount': 'Chunks ready',
'ingestion.successMessage': 'Indexing completed successfully!',
'ingestion.ingesting': 'Ingesting...', 'ingestion.ingesting': 'Ingesting...',
'ingestion.reindex': 'Re-index', 'ingestion.reindex': 'Re-index',
'ingestion.indexed': 'Indexed', 'ingestion.indexed': 'Indexed',

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"