feat(#225,#224): vocabulary rename index→ingest + stale stores strip

- #225: rename all push/index/pousser/indexé labels to ingest/ingérer/ingéré
  across status tooltips, docs/chunks push modals, and legacy ingestion panel
- #224: add DocStoreLink type + storeLinks on Document; StaleStoresStrip
  component shows per-store state badges with one-click re-ingest buttons
This commit is contained in:
Pier-Jean Malandrino 2026-04-30 10:04:01 +02:00
parent cab3e53fe4
commit e44fbb134e
5 changed files with 209 additions and 37 deletions

View file

@ -0,0 +1,143 @@
<template>
<div v-if="storeLinks.length > 0" class="stale-strip" data-e2e="stale-strip">
<div class="stale-strip__stores">
<div v-for="link in storeLinks" :key="link.store" class="store-row">
<span class="store-name">{{ link.store }}</span>
<StatusBadge :state="link.state" compact />
<span v-if="link.pushedAt" class="store-date">
{{ t('chunks.stale.pushedAt', { date: formatRelativeTime(link.pushedAt, locale) }) }}
</span>
<button
v-if="link.state === 'Stale'"
class="btn-reingest"
:disabled="reingestingStores.has(link.store)"
@click="reingest(link.store)"
>
{{ t('chunks.stale.reingest') }}
</button>
</div>
</div>
<button
v-if="staleCount > 1"
class="btn-reingest-all"
:disabled="reingestingStores.size > 0"
@click="reingestAll"
>
{{ t('chunks.stale.reingestAll') }}
</button>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import type { DocStoreLink } from '../../../shared/types'
import { pushChunksToStore } from '../api'
import StatusBadge from '../../document/ui/StatusBadge.vue'
import { useI18n } from '../../../shared/i18n'
import { formatRelativeTime } from '../../../shared/format'
import { appLocale } from '../../../shared/appConfig'
const props = defineProps<{
docId: string
storeLinks: DocStoreLink[]
}>()
const { t } = useI18n()
const locale = appLocale
const reingestingStores = ref<Set<string>>(new Set())
const staleCount = computed(() => props.storeLinks.filter((l) => l.state === 'Stale').length)
async function reingest(store: string): Promise<void> {
const next = new Set(reingestingStores.value)
next.add(store)
reingestingStores.value = next
try {
await pushChunksToStore(props.docId, store)
} catch {
// silent user can retry
} finally {
const after = new Set(reingestingStores.value)
after.delete(store)
reingestingStores.value = after
}
}
async function reingestAll(): Promise<void> {
const stale = props.storeLinks.filter((l) => l.state === 'Stale').map((l) => l.store)
await Promise.all(stale.map(reingest))
}
</script>
<style scoped>
.stale-strip {
display: flex;
align-items: center;
gap: 12px;
padding: 6px 16px;
background: var(--bg-surface);
border-bottom: 1px solid var(--border);
flex-wrap: wrap;
flex-shrink: 0;
}
.stale-strip__stores {
display: flex;
flex-wrap: wrap;
gap: 8px;
flex: 1;
}
.store-row {
display: flex;
align-items: center;
gap: 6px;
padding: 2px 8px;
background: var(--bg);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
font-size: 12px;
}
.store-name {
font-weight: 500;
color: var(--text-secondary);
}
.store-date {
color: var(--text-muted);
font-size: 11px;
}
.btn-reingest,
.btn-reingest-all {
padding: 2px 8px;
font-size: 11px;
font-weight: 500;
background: var(--warning-bg, rgba(234, 179, 8, 0.1));
color: var(--warning, #ca8a04);
border: 1px solid var(--warning, #ca8a04);
border-radius: var(--radius-sm);
cursor: pointer;
transition: opacity var(--transition);
white-space: nowrap;
}
.btn-reingest:disabled,
.btn-reingest-all:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btn-reingest:hover:not(:disabled),
.btn-reingest-all:hover:not(:disabled) {
opacity: 0.8;
}
.btn-reingest-all {
flex-shrink: 0;
padding: 4px 10px;
font-size: 12px;
}
</style>

View file

@ -25,6 +25,11 @@
<!-- Chunks editor --> <!-- Chunks editor -->
<div class="editor-pane"> <div class="editor-pane">
<StaleStoresStrip
v-if="storeLinks && storeLinks.length > 0"
:doc-id="docId"
:store-links="storeLinks"
/>
<ChunksEditor <ChunksEditor
:doc-id="docId" :doc-id="docId"
:available-stores="availableStores" :available-stores="availableStores"
@ -36,15 +41,17 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted } from 'vue' import { ref, onMounted } from 'vue'
import type { DocTreeNode } from '../shared/types' import type { DocTreeNode, DocStoreLink } from '../shared/types'
import { fetchDocumentTree } from '../features/document/api' import { fetchDocumentTree } from '../features/document/api'
import DocTreeRail from '../features/document/ui/DocTreeRail.vue' import DocTreeRail from '../features/document/ui/DocTreeRail.vue'
import ChunksEditor from '../features/chunks/ui/ChunksEditor.vue' import ChunksEditor from '../features/chunks/ui/ChunksEditor.vue'
import StaleStoresStrip from '../features/chunks/ui/StaleStoresStrip.vue'
import { useI18n } from '../shared/i18n' import { useI18n } from '../shared/i18n'
const props = defineProps<{ const props = defineProps<{
docId: string docId: string
availableStores: string[] availableStores: string[]
storeLinks?: DocStoreLink[]
}>() }>()
const { t } = useI18n() const { t } = useI18n()

View file

@ -41,6 +41,7 @@
v-if="activeMode === 'chunks'" v-if="activeMode === 'chunks'"
:doc-id="id" :doc-id="id"
:available-stores="doc.stores ?? []" :available-stores="doc.stores ?? []"
:store-links="doc.storeLinks"
/> />
<DocInspectTab v-else-if="activeMode === 'inspect'" :doc-id="id" /> <DocInspectTab v-else-if="activeMode === 'inspect'" :doc-id="id" />
<DocAskTab v-else-if="activeMode === 'ask'" :doc-id="id" /> <DocAskTab v-else-if="activeMode === 'ask'" :doc-id="id" />

View file

@ -46,9 +46,9 @@ const messages: Messages = {
'status.Failed': 'Échoué', 'status.Failed': 'Échoué',
'status.tooltip.Uploaded': 'Fichier reçu, en attente de parsing.', 'status.tooltip.Uploaded': 'Fichier reçu, en attente de parsing.',
'status.tooltip.Parsed': 'Document parsé avec succès.', 'status.tooltip.Parsed': 'Document parsé avec succès.',
'status.tooltip.Chunked': 'Chunks générés, prêts à être poussés vers un store.', 'status.tooltip.Chunked': 'Chunks générés, prêts à être ingérés dans un store.',
'status.tooltip.Ingested': 'Indexé dans au moins un store.', 'status.tooltip.Ingested': 'Ingéré dans au moins un store.',
'status.tooltip.Stale': 'Chunks modifiés depuis le dernier push — re-ingestion requise.', 'status.tooltip.Stale': 'Chunks modifiés depuis la dernière ingestion — ré-ingestion requise.',
'status.tooltip.Failed': 'Une étape du pipeline a échoué. Réessayez.', 'status.tooltip.Failed': 'Une étape du pipeline a échoué. Réessayez.',
// Document library (#211, #212, #213) // Document library (#211, #212, #213)
@ -66,14 +66,14 @@ const messages: Messages = {
'docs.filterClear': 'Effacer les filtres', 'docs.filterClear': 'Effacer les filtres',
'docs.selected': '{n} sélectionné(s)', 'docs.selected': '{n} sélectionné(s)',
'docs.bulkRechunk': 'Re-chunker', 'docs.bulkRechunk': 'Re-chunker',
'docs.bulkPush': 'Pousser vers un store…', 'docs.bulkPush': 'Ingérer dans un store…',
'docs.bulkDelete': 'Supprimer', 'docs.bulkDelete': 'Supprimer',
'docs.bulkCancel': 'Annuler la sélection', 'docs.bulkCancel': 'Annuler la sélection',
'docs.deleteConfirm': 'Supprimer {n} document(s) ? Cette action est irréversible.', 'docs.deleteConfirm': 'Supprimer {n} document(s) ? Cette action est irréversible.',
'docs.pushTitle': 'Pousser vers un store', 'docs.pushTitle': 'Ingérer dans un store',
'docs.pushLabel': 'Store cible', 'docs.pushLabel': 'Store cible',
'docs.pushPlaceholder': 'Nom du store…', 'docs.pushPlaceholder': 'Nom du store…',
'docs.pushSubmit': 'Pousser', 'docs.pushSubmit': 'Ingérer',
'docs.pushCancel': 'Annuler', 'docs.pushCancel': 'Annuler',
'docs.jobDispatched': 'Job lancé ({jobId})', 'docs.jobDispatched': 'Job lancé ({jobId})',
@ -316,33 +316,33 @@ const messages: Messages = {
'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. Coming soon !', '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. Coming soon !',
// Search (legacy nav label moved to the top with the other legacy keys) // Search (legacy nav label moved to the top with the other legacy keys)
'search.hint': 'Saisissez un terme pour rechercher dans les chunks indexés.', 'search.hint': 'Saisissez un terme pour rechercher dans les chunks ingérés.',
// Ingestion / My Documents // Ingestion / My Documents
'ingestion.ingest': 'Ingérer', 'ingestion.ingest': 'Ingérer',
'ingestion.document': 'Document', 'ingestion.document': 'Document',
'ingestion.chunkCount': 'Chunks prêts', 'ingestion.chunkCount': 'Chunks prêts',
'ingestion.successMessage': 'Indexation terminée avec succès !', 'ingestion.successMessage': 'Ingestion terminée avec succès !',
'ingestion.ingesting': 'Ingestion...', 'ingestion.ingesting': 'Ingestion...',
'ingestion.reindex': 'Ré-indexer', 'ingestion.reindex': 'Ré-ingérer',
'ingestion.indexed': 'Indexé', 'ingestion.indexed': 'Ingéré',
'ingestion.notIndexed': 'Non indexé', 'ingestion.notIndexed': 'Non ingéré',
'ingestion.chunksIndexed': '{n} chunks indexés', 'ingestion.chunksIndexed': '{n} chunks ingérés',
'ingestion.openInStudio': 'Ouvrir dans le Studio', 'ingestion.openInStudio': 'Ouvrir dans le Studio',
'ingestion.deleteIndex': "Supprimer de l'index", 'ingestion.deleteIndex': 'Retirer du store',
'ingestion.deleteConfirm': 'ingestion.deleteConfirm':
'Retirer ce document de l\u2019index ? Les chunks seront supprimés mais le document source restera.', 'Retirer ce document de l\u2019index ? Les chunks seront supprimés mais le document source restera.',
'ingestion.unavailable': 'Ingestion non disponible', 'ingestion.unavailable': 'Ingestion non disponible',
'ingestion.filterAll': 'Tous', 'ingestion.filterAll': 'Tous',
'ingestion.filterIndexed': 'Indexés', 'ingestion.filterIndexed': 'Ingérés',
'ingestion.filterNotIndexed': 'Non indexés', 'ingestion.filterNotIndexed': 'Non ingérés',
'ingestion.sortName': 'Nom', 'ingestion.sortName': 'Nom',
'ingestion.sortDate': 'Date', 'ingestion.sortDate': 'Date',
'ingestion.search': 'Rechercher...', 'ingestion.search': 'Rechercher...',
'ingestion.searchChunks': 'Rechercher dans les chunks…', 'ingestion.searchChunks': 'Rechercher dans les chunks…',
'ingestion.noResults': 'Aucun résultat pour « {q} ».', 'ingestion.noResults': 'Aucun résultat pour « {q} ».',
'ingestion.stepEmbedding': 'Embedding…', 'ingestion.stepEmbedding': 'Embedding…',
'ingestion.stepIndexing': 'Indexation…', 'ingestion.stepIndexing': 'Ingestion…',
'ingestion.stepDone': 'Terminé', 'ingestion.stepDone': 'Terminé',
'ingestion.opensearchConnected': 'OpenSearch connecté', 'ingestion.opensearchConnected': 'OpenSearch connecté',
'ingestion.opensearchDisconnected': 'OpenSearch déconnecté', 'ingestion.opensearchDisconnected': 'OpenSearch déconnecté',
@ -396,13 +396,19 @@ const messages: Messages = {
'chunks.diffAdded': 'ajout\u00e9(s)', 'chunks.diffAdded': 'ajout\u00e9(s)',
'chunks.diffModified': 'modifi\u00e9(s)', 'chunks.diffModified': 'modifi\u00e9(s)',
'chunks.diffRemoved': 'supprim\u00e9(s)', 'chunks.diffRemoved': 'supprim\u00e9(s)',
'chunks.pushTitle': 'Pousser vers un store', 'chunks.pushTitle': 'Ing\u00e9rer dans un store',
'chunks.pushStore': 'Store cible', 'chunks.pushStore': 'Store cible',
'chunks.pushPlaceholder': 'Nom du store\u2026', 'chunks.pushPlaceholder': 'Nom du store\u2026',
'chunks.pushSummary': '{embeds} chunks \u00b7 ~{tokens} tokens', 'chunks.pushSummary': '{embeds} chunks \u00b7 ~{tokens} tokens',
'chunks.pushConfirm': 'Pousser', 'chunks.pushConfirm': 'Ing\u00e9rer',
'chunks.pushCancel': 'Annuler', 'chunks.pushCancel': 'Annuler',
'chunks.pushedJob': 'Job lanc\u00e9 : {jobId}', 'chunks.pushedJob': 'Job lanc\u00e9 : {jobId}',
// Stale stores strip (#224)
'chunks.stale.pushedAt': 'ing\u00e9r\u00e9 le {date}',
'chunks.stale.reingest': 'R\u00e9-ing\u00e9rer',
'chunks.stale.reingestAll': 'R\u00e9-ing\u00e9rer tous les obsol\u00e8tes',
'chunks.stale.jobDispatched': 'Job lanc\u00e9 : {jobId}',
'chunks.selected': '{n} s\u00e9lectionn\u00e9(s)', 'chunks.selected': '{n} s\u00e9lectionn\u00e9(s)',
'chunks.bulkDrop': 'Supprimer la s\u00e9lection', 'chunks.bulkDrop': 'Supprimer la s\u00e9lection',
'chunks.bulkMerge': 'Fusionner la s\u00e9lection', 'chunks.bulkMerge': 'Fusionner la s\u00e9lection',
@ -450,9 +456,9 @@ const messages: Messages = {
'status.Failed': 'Failed', 'status.Failed': 'Failed',
'status.tooltip.Uploaded': 'File received, awaiting parsing.', 'status.tooltip.Uploaded': 'File received, awaiting parsing.',
'status.tooltip.Parsed': 'Document parsed successfully.', 'status.tooltip.Parsed': 'Document parsed successfully.',
'status.tooltip.Chunked': 'Chunks generated, ready to push to a store.', 'status.tooltip.Chunked': 'Chunks generated, ready to ingest into a store.',
'status.tooltip.Ingested': 'Indexed in at least one store.', 'status.tooltip.Ingested': 'Ingested into at least one store.',
'status.tooltip.Stale': 'Chunks modified since last push — re-ingestion required.', 'status.tooltip.Stale': 'Chunks modified since last ingestion — re-ingestion required.',
'status.tooltip.Failed': 'A pipeline step failed. Retry to recover.', 'status.tooltip.Failed': 'A pipeline step failed. Retry to recover.',
// Document library (#211, #212, #213) // Document library (#211, #212, #213)
@ -470,14 +476,14 @@ const messages: Messages = {
'docs.filterClear': 'Clear filters', 'docs.filterClear': 'Clear filters',
'docs.selected': '{n} selected', 'docs.selected': '{n} selected',
'docs.bulkRechunk': 'Re-chunk', 'docs.bulkRechunk': 'Re-chunk',
'docs.bulkPush': 'Push to store…', 'docs.bulkPush': 'Ingest into store…',
'docs.bulkDelete': 'Delete', 'docs.bulkDelete': 'Delete',
'docs.bulkCancel': 'Cancel selection', 'docs.bulkCancel': 'Cancel selection',
'docs.deleteConfirm': 'Delete {n} document(s)? This action cannot be undone.', 'docs.deleteConfirm': 'Delete {n} document(s)? This action cannot be undone.',
'docs.pushTitle': 'Push to store', 'docs.pushTitle': 'Ingest into store',
'docs.pushLabel': 'Target store', 'docs.pushLabel': 'Target store',
'docs.pushPlaceholder': 'Store name…', 'docs.pushPlaceholder': 'Store name…',
'docs.pushSubmit': 'Push', 'docs.pushSubmit': 'Ingest',
'docs.pushCancel': 'Cancel', 'docs.pushCancel': 'Cancel',
'docs.jobDispatched': 'Job dispatched ({jobId})', 'docs.jobDispatched': 'Job dispatched ({jobId})',
@ -710,32 +716,32 @@ 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. Coming soon!', 'Chunking is not available for this analysis. Large documents processed in batch mode do not generate the internal structure required for chunking. Coming soon!',
'search.hint': 'Enter a term to search through indexed chunks.', 'search.hint': 'Enter a term to search through ingested chunks.',
'ingestion.ingest': 'Ingest', 'ingestion.ingest': 'Ingest',
'ingestion.document': 'Document', 'ingestion.document': 'Document',
'ingestion.chunkCount': 'Chunks ready', 'ingestion.chunkCount': 'Chunks ready',
'ingestion.successMessage': 'Indexing completed successfully!', 'ingestion.successMessage': 'Ingestion completed successfully!',
'ingestion.ingesting': 'Ingesting...', 'ingestion.ingesting': 'Ingesting...',
'ingestion.reindex': 'Re-index', 'ingestion.reindex': 'Re-ingest',
'ingestion.indexed': 'Indexed', 'ingestion.indexed': 'Ingested',
'ingestion.notIndexed': 'Not indexed', 'ingestion.notIndexed': 'Not ingested',
'ingestion.chunksIndexed': '{n} chunks indexed', 'ingestion.chunksIndexed': '{n} chunks ingested',
'ingestion.openInStudio': 'Open in Studio', 'ingestion.openInStudio': 'Open in Studio',
'ingestion.deleteIndex': 'Remove from index', 'ingestion.deleteIndex': 'Remove from store',
'ingestion.deleteConfirm': 'ingestion.deleteConfirm':
'Remove this document from the index? Chunks will be deleted but the source document will remain.', 'Remove this document from the index? Chunks will be deleted but the source document will remain.',
'ingestion.unavailable': 'Ingestion unavailable', 'ingestion.unavailable': 'Ingestion unavailable',
'ingestion.filterAll': 'All', 'ingestion.filterAll': 'All',
'ingestion.filterIndexed': 'Indexed', 'ingestion.filterIndexed': 'Ingested',
'ingestion.filterNotIndexed': 'Not indexed', 'ingestion.filterNotIndexed': 'Not ingested',
'ingestion.sortName': 'Name', 'ingestion.sortName': 'Name',
'ingestion.sortDate': 'Date', 'ingestion.sortDate': 'Date',
'ingestion.search': 'Search...', 'ingestion.search': 'Search...',
'ingestion.searchChunks': 'Search indexed chunks…', 'ingestion.searchChunks': 'Search indexed chunks…',
'ingestion.noResults': 'No results for "{q}".', 'ingestion.noResults': 'No results for "{q}".',
'ingestion.stepEmbedding': 'Embedding…', 'ingestion.stepEmbedding': 'Embedding…',
'ingestion.stepIndexing': 'Indexing…', 'ingestion.stepIndexing': 'Ingesting…',
'ingestion.stepDone': 'Done', 'ingestion.stepDone': 'Done',
'ingestion.opensearchConnected': 'OpenSearch connected', 'ingestion.opensearchConnected': 'OpenSearch connected',
'ingestion.opensearchDisconnected': 'OpenSearch unreachable', 'ingestion.opensearchDisconnected': 'OpenSearch unreachable',
@ -786,13 +792,19 @@ const messages: Messages = {
'chunks.diffAdded': 'added', 'chunks.diffAdded': 'added',
'chunks.diffModified': 'modified', 'chunks.diffModified': 'modified',
'chunks.diffRemoved': 'removed', 'chunks.diffRemoved': 'removed',
'chunks.pushTitle': 'Push to store', 'chunks.pushTitle': 'Ingest into store',
'chunks.pushStore': 'Target store', 'chunks.pushStore': 'Target store',
'chunks.pushPlaceholder': 'Store name\u2026', 'chunks.pushPlaceholder': 'Store name\u2026',
'chunks.pushSummary': '{embeds} chunks \u00b7 ~{tokens} tokens', 'chunks.pushSummary': '{embeds} chunks \u00b7 ~{tokens} tokens',
'chunks.pushConfirm': 'Push', 'chunks.pushConfirm': 'Ingest',
'chunks.pushCancel': 'Cancel', 'chunks.pushCancel': 'Cancel',
'chunks.pushedJob': 'Job dispatched: {jobId}', 'chunks.pushedJob': 'Job dispatched: {jobId}',
// Stale stores strip (#224)
'chunks.stale.pushedAt': 'ingested on {date}',
'chunks.stale.reingest': 'Re-ingest',
'chunks.stale.reingestAll': 'Re-ingest all stale',
'chunks.stale.jobDispatched': 'Job dispatched: {jobId}',
'chunks.selected': '{n} selected', 'chunks.selected': '{n} selected',
'chunks.bulkDrop': 'Drop selected', 'chunks.bulkDrop': 'Drop selected',
'chunks.bulkMerge': 'Merge selected', 'chunks.bulkMerge': 'Merge selected',

View file

@ -17,6 +17,13 @@ export type DocumentLifecycleState =
| 'Stale' | 'Stale'
| 'Failed' | 'Failed'
/** Per-store ingestion state for a document (#224). */
export interface DocStoreLink {
store: string
state: DocumentLifecycleState
pushedAt: string | null
}
export interface Document { export interface Document {
id: string id: string
filename: string filename: string
@ -30,6 +37,8 @@ export interface Document {
lifecycleStateAt: string | null lifecycleStateAt: string | null
/** Stores this document has been pushed to (added in E1 #203). */ /** Stores this document has been pushed to (added in E1 #203). */
stores?: string[] stores?: string[]
/** Per-store state detail (#224). Present when backend returns storeLinks. */
storeLinks?: DocStoreLink[]
} }
export interface PipelineOptions { export interface PipelineOptions {