Merge pull request #248 from scub-france/feature/243-stores
feat(#243,#244,#245): E8 — Stores pages (list, detail, query)
This commit is contained in:
commit
ed50756ccd
7 changed files with 1343 additions and 21 deletions
57
frontend/src/features/store/api.test.ts
Normal file
57
frontend/src/features/store/api.test.ts
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { fetchStores, fetchStoreDocuments, removeDocumentFromStore, queryStore } from './api'
|
||||
|
||||
vi.mock('../../shared/api/http', () => ({
|
||||
apiFetch: vi.fn(),
|
||||
}))
|
||||
|
||||
import { apiFetch } from '../../shared/api/http'
|
||||
|
||||
describe('store API', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('fetchStores calls GET /api/stores', async () => {
|
||||
const stores = [{ name: 'my-store', type: 'opensearch', connected: true }]
|
||||
apiFetch.mockResolvedValue(stores)
|
||||
|
||||
const result = await fetchStores()
|
||||
|
||||
expect(apiFetch).toHaveBeenCalledWith('/api/stores')
|
||||
expect(result).toEqual(stores)
|
||||
})
|
||||
|
||||
it('fetchStoreDocuments calls GET /api/stores/:store/documents', async () => {
|
||||
const docs = [{ docId: 'doc-1', filename: 'test.pdf', state: 'Ingested', chunkCount: 12 }]
|
||||
apiFetch.mockResolvedValue(docs)
|
||||
|
||||
const result = await fetchStoreDocuments('my-store')
|
||||
|
||||
expect(apiFetch).toHaveBeenCalledWith('/api/stores/my-store/documents')
|
||||
expect(result).toEqual(docs)
|
||||
})
|
||||
|
||||
it('removeDocumentFromStore calls DELETE /api/stores/:store/documents/:docId', async () => {
|
||||
apiFetch.mockResolvedValue(undefined)
|
||||
|
||||
await removeDocumentFromStore('my-store', 'doc-1')
|
||||
|
||||
expect(apiFetch).toHaveBeenCalledWith('/api/stores/my-store/documents/doc-1', {
|
||||
method: 'DELETE',
|
||||
})
|
||||
})
|
||||
|
||||
it('queryStore calls POST /api/stores/:store/query with body', async () => {
|
||||
const results = [{ chunkId: 'c1', docId: 'd1', filename: 'a.pdf', text: 'hi', score: 0.9 }]
|
||||
apiFetch.mockResolvedValue(results)
|
||||
|
||||
const result = await queryStore('my-store', 'what is X?', 3)
|
||||
|
||||
expect(apiFetch).toHaveBeenCalledWith('/api/stores/my-store/query', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ query: 'what is X?', top_k: 3 }),
|
||||
})
|
||||
expect(result).toEqual(results)
|
||||
})
|
||||
})
|
||||
50
frontend/src/features/store/api.ts
Normal file
50
frontend/src/features/store/api.ts
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
import { apiFetch } from '../../shared/api/http'
|
||||
import type { DocumentLifecycleState } from '../../shared/types'
|
||||
|
||||
export interface StoreInfo {
|
||||
name: string
|
||||
type: string
|
||||
connected: boolean
|
||||
documentCount: number
|
||||
chunkCount: number
|
||||
errorMessage?: string
|
||||
}
|
||||
|
||||
export interface StoreDocEntry {
|
||||
docId: string
|
||||
filename: string
|
||||
state: DocumentLifecycleState
|
||||
chunkCount: number
|
||||
pushedAt: string
|
||||
}
|
||||
|
||||
export interface QueryResult {
|
||||
chunkId: string
|
||||
docId: string
|
||||
filename: string
|
||||
text: string
|
||||
score: number
|
||||
pageRange?: [number, number]
|
||||
}
|
||||
|
||||
export function fetchStores(): Promise<StoreInfo[]> {
|
||||
return apiFetch<StoreInfo[]>('/api/stores')
|
||||
}
|
||||
|
||||
export function fetchStoreDocuments(store: string): Promise<StoreDocEntry[]> {
|
||||
return apiFetch<StoreDocEntry[]>(`/api/stores/${encodeURIComponent(store)}/documents`)
|
||||
}
|
||||
|
||||
export function removeDocumentFromStore(store: string, docId: string): Promise<void> {
|
||||
return apiFetch<void>(
|
||||
`/api/stores/${encodeURIComponent(store)}/documents/${encodeURIComponent(docId)}`,
|
||||
{ method: 'DELETE' },
|
||||
)
|
||||
}
|
||||
|
||||
export function queryStore(store: string, query: string, topK = 5): Promise<QueryResult[]> {
|
||||
return apiFetch<QueryResult[]>(`/api/stores/${encodeURIComponent(store)}/query`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ query, top_k: topK }),
|
||||
})
|
||||
}
|
||||
1
frontend/src/features/store/index.ts
Normal file
1
frontend/src/features/store/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export * from './api'
|
||||
|
|
@ -1,20 +1,468 @@
|
|||
<template>
|
||||
<ComingSoonShell
|
||||
:title="t('comingSoon.title')"
|
||||
:subtitle="t('comingSoon.subtitle.storeDetail')"
|
||||
:hint="hint"
|
||||
/>
|
||||
<div class="detail-page">
|
||||
<div class="detail-header">
|
||||
<RouterLink :to="{ name: ROUTES.STORES_LIST }" class="back-link">
|
||||
← {{ t('storeDetail.back') }}
|
||||
</RouterLink>
|
||||
<h1 class="detail-title">{{ store }}</h1>
|
||||
<RouterLink :to="{ name: ROUTES.STORE_QUERY, params: { store } }" class="btn-primary">
|
||||
{{ t('storeDetail.query') }}
|
||||
</RouterLink>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="loading-state">
|
||||
<div class="spinner" />
|
||||
</div>
|
||||
|
||||
<div v-else-if="error" class="error-state">
|
||||
<p class="error-text">{{ error }}</p>
|
||||
<button class="btn-secondary" @click="load">Retry</button>
|
||||
</div>
|
||||
|
||||
<div v-else-if="docs.length" class="table-wrapper">
|
||||
<table class="detail-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="col-check">
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="allSelected"
|
||||
:indeterminate="someSelected"
|
||||
@change="toggleAll"
|
||||
/>
|
||||
</th>
|
||||
<th>{{ t('storeDetail.colDoc') }}</th>
|
||||
<th>{{ t('storeDetail.colState') }}</th>
|
||||
<th class="col-num">{{ t('storeDetail.colChunks') }}</th>
|
||||
<th>{{ t('storeDetail.colIngested') }}</th>
|
||||
<th class="col-action" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="doc in docs" :key="doc.docId" class="doc-row" @click="openDoc(doc.docId)">
|
||||
<td class="col-check" @click.stop>
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="selectedIds.has(doc.docId)"
|
||||
@change="toggleDoc(doc.docId)"
|
||||
/>
|
||||
</td>
|
||||
<td class="col-name">
|
||||
<svg class="doc-icon" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M4 4a2 2 0 012-2h4.586A2 2 0 0112 2.586L15.414 6A2 2 0 0116 7.414V16a2 2 0 01-2 2H6a2 2 0 01-2-2V4z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
<span class="doc-filename" :title="doc.filename">{{ doc.filename }}</span>
|
||||
</td>
|
||||
<td>
|
||||
<StatusBadge :state="doc.state" />
|
||||
</td>
|
||||
<td class="col-num">{{ doc.chunkCount }}</td>
|
||||
<td class="col-date">{{ doc.pushedAt ? formatDate(doc.pushedAt) : '—' }}</td>
|
||||
<td class="col-action" @click.stop>
|
||||
<button class="btn-sm btn-sm--danger" @click="removeDoc(doc)">
|
||||
{{ t('storeDetail.remove') }}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div v-else class="empty-state">
|
||||
<p class="empty-title">{{ t('storeDetail.empty') }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Bulk action bar -->
|
||||
<div v-if="selectedIds.size > 0" class="bulk-bar">
|
||||
<span class="bulk-count">{{ t('storeDetail.selected', { n: selectedIds.size }) }}</span>
|
||||
<div class="bulk-actions">
|
||||
<button class="btn-sm btn-sm--danger" @click="bulkRemove">
|
||||
{{ t('storeDetail.bulkRemove') }}
|
||||
</button>
|
||||
<button class="btn-sm btn-sm--ghost" @click="clearSelection">
|
||||
{{ t('storeDetail.bulkCancel') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from '../shared/i18n'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { RouterLink, useRouter } from 'vue-router'
|
||||
|
||||
import ComingSoonShell from '../shared/ui/ComingSoonShell.vue'
|
||||
import {
|
||||
fetchStoreDocuments,
|
||||
removeDocumentFromStore,
|
||||
type StoreDocEntry,
|
||||
} from '../features/store/api'
|
||||
import StatusBadge from '../features/document/ui/StatusBadge.vue'
|
||||
import { useI18n } from '../shared/i18n'
|
||||
import { ROUTES } from '../shared/routing/names'
|
||||
import { appLocale } from '../shared/appConfig'
|
||||
|
||||
const props = defineProps<{ store: string }>()
|
||||
|
||||
const router = useRouter()
|
||||
const { t } = useI18n()
|
||||
|
||||
const hint = computed(() => t('comingSoon.hint.storeDetail', { store: props.store }))
|
||||
const docs = ref<StoreDocEntry[]>([])
|
||||
const loading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
const selectedIds = ref<Set<string>>(new Set())
|
||||
|
||||
async function load(): Promise<void> {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
docs.value = await fetchStoreDocuments(props.store)
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : String(e)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function formatDate(iso: string): string {
|
||||
return new Date(iso).toLocaleDateString(appLocale.value === 'fr' ? 'fr-FR' : 'en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
})
|
||||
}
|
||||
|
||||
function openDoc(docId: string): void {
|
||||
router.push({ name: ROUTES.DOC_WORKSPACE, params: { id: docId } })
|
||||
}
|
||||
|
||||
async function removeDoc(doc: StoreDocEntry): Promise<void> {
|
||||
if (!window.confirm(t('storeDetail.removeConfirm', { doc: doc.filename }))) return
|
||||
await removeDocumentFromStore(props.store, doc.docId)
|
||||
docs.value = docs.value.filter((d) => d.docId !== doc.docId)
|
||||
const next = new Set(selectedIds.value)
|
||||
next.delete(doc.docId)
|
||||
selectedIds.value = next
|
||||
}
|
||||
|
||||
const allSelected = computed(
|
||||
() => docs.value.length > 0 && docs.value.every((d) => selectedIds.value.has(d.docId)),
|
||||
)
|
||||
|
||||
const someSelected = computed(
|
||||
() => docs.value.some((d) => selectedIds.value.has(d.docId)) && !allSelected.value,
|
||||
)
|
||||
|
||||
function toggleDoc(id: string): void {
|
||||
const next = new Set(selectedIds.value)
|
||||
if (next.has(id)) next.delete(id)
|
||||
else next.add(id)
|
||||
selectedIds.value = next
|
||||
}
|
||||
|
||||
function toggleAll(): void {
|
||||
if (allSelected.value) {
|
||||
selectedIds.value = new Set()
|
||||
} else {
|
||||
selectedIds.value = new Set(docs.value.map((d) => d.docId))
|
||||
}
|
||||
}
|
||||
|
||||
function clearSelection(): void {
|
||||
selectedIds.value = new Set()
|
||||
}
|
||||
|
||||
async function bulkRemove(): Promise<void> {
|
||||
const n = selectedIds.value.size
|
||||
if (!window.confirm(t('storeDetail.bulkConfirm', { n }))) return
|
||||
const ids = [...selectedIds.value]
|
||||
clearSelection()
|
||||
await Promise.all(ids.map((id) => removeDocumentFromStore(props.store, id)))
|
||||
docs.value = docs.value.filter((d) => !ids.includes(d.docId))
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.detail-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.detail-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 20px 24px 16px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.back-link {
|
||||
font-size: 13px;
|
||||
color: var(--text-muted);
|
||||
text-decoration: none;
|
||||
transition: color var(--transition);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.back-link:hover {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.detail-title {
|
||||
flex: 1;
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
font-family: 'IBM Plex Mono', monospace;
|
||||
color: var(--text);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.loading-state {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 60px;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border: 2px solid var(--border-light);
|
||||
border-top-color: var(--accent);
|
||||
border-radius: 50%;
|
||||
animation: spin 0.6s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.error-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 60px 24px;
|
||||
}
|
||||
|
||||
.error-text {
|
||||
font-size: 13px;
|
||||
color: var(--error);
|
||||
}
|
||||
|
||||
.table-wrapper {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 0 24px 80px;
|
||||
}
|
||||
|
||||
.detail-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.detail-table thead {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
background: var(--bg);
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.detail-table th {
|
||||
padding: 10px 12px;
|
||||
text-align: left;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.col-check {
|
||||
width: 40px;
|
||||
}
|
||||
|
||||
.col-num {
|
||||
width: 80px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.detail-table td.col-num {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.col-action {
|
||||
width: 80px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.doc-row {
|
||||
cursor: pointer;
|
||||
border-bottom: 1px solid var(--border);
|
||||
transition: background var(--transition);
|
||||
}
|
||||
|
||||
.doc-row:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.detail-table td {
|
||||
padding: 12px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.col-name {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.doc-icon {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
color: var(--accent);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.doc-filename {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-weight: 500;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.col-date {
|
||||
font-size: 12px;
|
||||
font-family: 'IBM Plex Mono', monospace;
|
||||
color: var(--text-muted);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
padding: 80px 24px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.empty-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.bulk-bar {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 24px;
|
||||
background: var(--bg-elevated);
|
||||
border-top: 1px solid var(--border);
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.bulk-count {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.bulk-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 7px 14px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: white;
|
||||
background: var(--accent);
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
transition: background var(--transition);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: var(--accent-hover);
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
padding: 7px 14px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
background: var(--bg-elevated);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
transition: all var(--transition);
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.btn-sm {
|
||||
padding: 5px 10px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
transition: all var(--transition);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.btn-sm:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.btn-sm--danger {
|
||||
color: var(--error);
|
||||
border-color: rgba(239, 68, 68, 0.3);
|
||||
}
|
||||
|
||||
.btn-sm--danger:hover {
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
}
|
||||
|
||||
.btn-sm--ghost {
|
||||
border-color: transparent;
|
||||
background: transparent;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -1,20 +1,389 @@
|
|||
<template>
|
||||
<ComingSoonShell
|
||||
:title="t('comingSoon.title')"
|
||||
:subtitle="t('comingSoon.subtitle.storeQuery')"
|
||||
:hint="hint"
|
||||
/>
|
||||
<div class="query-page">
|
||||
<div class="query-header">
|
||||
<RouterLink :to="{ name: ROUTES.STORE_DETAIL, params: { store } }" class="back-link">
|
||||
← {{ t('storeQuery.back') }}
|
||||
</RouterLink>
|
||||
<h1 class="query-title">{{ store }}</h1>
|
||||
</div>
|
||||
|
||||
<div class="query-form">
|
||||
<div class="form-row">
|
||||
<label class="form-label" for="query-input">{{ t('storeQuery.queryLabel') }}</label>
|
||||
<div class="query-input-group">
|
||||
<textarea
|
||||
id="query-input"
|
||||
v-model="queryText"
|
||||
class="query-textarea"
|
||||
:placeholder="t('storeQuery.queryPlaceholder')"
|
||||
rows="3"
|
||||
@keydown.ctrl.enter="run"
|
||||
@keydown.meta.enter="run"
|
||||
/>
|
||||
<button class="btn-primary" :disabled="running || !queryText.trim()" @click="run">
|
||||
{{ running ? t('storeQuery.running') : t('storeQuery.run') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-row form-row--inline">
|
||||
<label class="form-label" for="topk-input">{{ t('storeQuery.topKLabel') }}</label>
|
||||
<input
|
||||
id="topk-input"
|
||||
v-model.number="topK"
|
||||
type="number"
|
||||
class="topk-input"
|
||||
min="1"
|
||||
max="50"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="error" class="error-banner">{{ error }}</div>
|
||||
|
||||
<div v-if="results !== null" class="results-section">
|
||||
<div v-if="results.length === 0" class="empty-results">
|
||||
{{ t('storeQuery.empty') }}
|
||||
</div>
|
||||
<table v-else class="results-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="col-score">{{ t('storeQuery.colScore') }}</th>
|
||||
<th>{{ t('storeQuery.colDoc') }}</th>
|
||||
<th>{{ t('storeQuery.colText') }}</th>
|
||||
<th class="col-page">{{ t('storeQuery.colPage') }}</th>
|
||||
<th class="col-view" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="(r, idx) in results" :key="idx" class="result-row">
|
||||
<td class="col-score">
|
||||
<span class="score-badge">{{ r.score.toFixed(3) }}</span>
|
||||
</td>
|
||||
<td class="col-doc">
|
||||
<span class="doc-name" :title="r.filename">{{ r.filename }}</span>
|
||||
</td>
|
||||
<td class="col-text">
|
||||
<span class="excerpt">{{ r.text }}</span>
|
||||
</td>
|
||||
<td class="col-page">
|
||||
<span v-if="r.pageRange" class="page-range">
|
||||
{{ r.pageRange[0] }}–{{ r.pageRange[1] }}
|
||||
</span>
|
||||
<span v-else class="no-value">—</span>
|
||||
</td>
|
||||
<td class="col-view">
|
||||
<RouterLink
|
||||
:to="{ name: ROUTES.DOC_WORKSPACE, params: { id: r.docId } }"
|
||||
class="view-link"
|
||||
>
|
||||
{{ t('storeQuery.viewDoc') }}
|
||||
</RouterLink>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from '../shared/i18n'
|
||||
import { ref } from 'vue'
|
||||
import { RouterLink } from 'vue-router'
|
||||
|
||||
import ComingSoonShell from '../shared/ui/ComingSoonShell.vue'
|
||||
import { queryStore, type QueryResult } from '../features/store/api'
|
||||
import { useI18n } from '../shared/i18n'
|
||||
import { ROUTES } from '../shared/routing/names'
|
||||
|
||||
const props = defineProps<{ store: string }>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const hint = computed(() => t('comingSoon.hint.storeQuery', { store: props.store }))
|
||||
const queryText = ref('')
|
||||
const topK = ref(5)
|
||||
const running = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
const results = ref<QueryResult[] | null>(null)
|
||||
|
||||
async function run(): Promise<void> {
|
||||
const q = queryText.value.trim()
|
||||
if (!q || running.value) return
|
||||
running.value = true
|
||||
error.value = null
|
||||
results.value = null
|
||||
try {
|
||||
results.value = await queryStore(props.store, q, topK.value)
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : String(e)
|
||||
} finally {
|
||||
running.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.query-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.query-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 20px 24px 16px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.back-link {
|
||||
font-size: 13px;
|
||||
color: var(--text-muted);
|
||||
text-decoration: none;
|
||||
transition: color var(--transition);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.back-link:hover {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.query-title {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
font-family: 'IBM Plex Mono', monospace;
|
||||
color: var(--text);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.query-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
padding: 16px 24px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.form-row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.form-row--inline {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.form-label {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.query-input-group {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.query-textarea {
|
||||
flex: 1;
|
||||
padding: 8px 10px;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--border);
|
||||
background: var(--bg-elevated);
|
||||
color: var(--text);
|
||||
font-size: 13px;
|
||||
font-family: inherit;
|
||||
outline: none;
|
||||
resize: vertical;
|
||||
transition: border-color var(--transition);
|
||||
}
|
||||
|
||||
.query-textarea:focus {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.topk-input {
|
||||
width: 80px;
|
||||
padding: 6px 8px;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--border);
|
||||
background: var(--bg-elevated);
|
||||
color: var(--text);
|
||||
font-size: 13px;
|
||||
outline: none;
|
||||
transition: border-color var(--transition);
|
||||
}
|
||||
|
||||
.topk-input:focus {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.error-banner {
|
||||
margin: 12px 24px 0;
|
||||
padding: 10px 14px;
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 13px;
|
||||
color: var(--error);
|
||||
background: rgba(239, 68, 68, 0.08);
|
||||
border: 1px solid rgba(239, 68, 68, 0.2);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.results-section {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 0 24px 24px;
|
||||
}
|
||||
|
||||
.empty-results {
|
||||
padding: 40px;
|
||||
text-align: center;
|
||||
font-size: 14px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.results-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.results-table thead {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
background: var(--bg);
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.results-table th {
|
||||
padding: 10px 12px;
|
||||
text-align: left;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.col-score {
|
||||
width: 80px;
|
||||
}
|
||||
|
||||
.col-page {
|
||||
width: 80px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.results-table td.col-page {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.col-view {
|
||||
width: 60px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.result-row {
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.results-table td {
|
||||
padding: 12px;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.score-badge {
|
||||
display: inline-block;
|
||||
padding: 2px 7px;
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 11px;
|
||||
font-family: 'IBM Plex Mono', monospace;
|
||||
font-weight: 600;
|
||||
background: var(--accent-muted);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.col-doc {
|
||||
max-width: 200px;
|
||||
}
|
||||
|
||||
.doc-name {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.col-text {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.excerpt {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 3;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.page-range {
|
||||
font-size: 12px;
|
||||
font-family: 'IBM Plex Mono', monospace;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.no-value {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.view-link {
|
||||
font-size: 12px;
|
||||
color: var(--accent);
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
transition: color var(--transition);
|
||||
}
|
||||
|
||||
.view-link:hover {
|
||||
color: var(--accent-hover);
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 8px 16px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: white;
|
||||
background: var(--accent);
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
transition: background var(--transition);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
background: var(--accent-hover);
|
||||
}
|
||||
|
||||
.btn-primary:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -1,11 +1,326 @@
|
|||
<template>
|
||||
<ComingSoonShell :title="t('comingSoon.title')" :subtitle="t('comingSoon.subtitle.stores')" />
|
||||
<div class="stores-page">
|
||||
<div class="stores-header">
|
||||
<h1 class="stores-title">{{ t('stores.title') }}</h1>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="loading-state">
|
||||
<div class="spinner" />
|
||||
</div>
|
||||
|
||||
<div v-else-if="error" class="error-state">
|
||||
<p class="error-text">{{ error }}</p>
|
||||
<button class="btn-secondary" @click="load">Retry</button>
|
||||
</div>
|
||||
|
||||
<div v-else-if="stores.length" class="table-wrapper">
|
||||
<table class="stores-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{ t('stores.colName') }}</th>
|
||||
<th>{{ t('stores.colType') }}</th>
|
||||
<th>{{ t('stores.colStatus') }}</th>
|
||||
<th class="col-num">{{ t('stores.colDocs') }}</th>
|
||||
<th class="col-num">{{ t('stores.colChunks') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="store in stores"
|
||||
:key="store.name"
|
||||
class="store-row"
|
||||
@click="openStore(store.name)"
|
||||
>
|
||||
<td class="col-name">
|
||||
<svg class="store-icon" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path
|
||||
d="M3 12v3c0 1.657 3.134 3 7 3s7-1.343 7-3v-3c0 1.657-3.134 3-7 3s-7-1.343-7-3z"
|
||||
/>
|
||||
<path
|
||||
d="M3 7v3c0 1.657 3.134 3 7 3s7-1.343 7-3V7c0 1.657-3.134 3-7 3S3 8.657 3 7z"
|
||||
/>
|
||||
<path d="M17 5c0 1.657-3.134 3-7 3S3 6.657 3 5s3.134-3 7-3 7 1.343 7 3z" />
|
||||
</svg>
|
||||
<span class="store-name">{{ store.name }}</span>
|
||||
</td>
|
||||
<td>
|
||||
<span class="store-type">{{ store.type }}</span>
|
||||
</td>
|
||||
<td>
|
||||
<span
|
||||
class="status-badge"
|
||||
:class="store.connected ? 'status-badge--ok' : 'status-badge--err'"
|
||||
>
|
||||
{{ store.connected ? t('stores.connected') : t('stores.disconnected') }}
|
||||
</span>
|
||||
<span v-if="store.errorMessage" class="error-hint" :title="store.errorMessage">
|
||||
{{ store.errorMessage }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="col-num">{{ store.documentCount }}</td>
|
||||
<td class="col-num">{{ store.chunkCount }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div v-else class="empty-state">
|
||||
<svg
|
||||
class="empty-icon"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1"
|
||||
>
|
||||
<ellipse cx="12" cy="5" rx="9" ry="3" />
|
||||
<path d="M21 12c0 1.657-4.03 3-9 3S3 13.657 3 12" />
|
||||
<path d="M3 5v14c0 1.657 4.03 3 9 3s9-1.343 9-3V5" />
|
||||
</svg>
|
||||
<p class="empty-title">{{ t('stores.empty') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
import { fetchStores, type StoreInfo } from '../features/store/api'
|
||||
import { useI18n } from '../shared/i18n'
|
||||
import { ROUTES } from '../shared/routing/names'
|
||||
|
||||
import ComingSoonShell from '../shared/ui/ComingSoonShell.vue'
|
||||
|
||||
const router = useRouter()
|
||||
const { t } = useI18n()
|
||||
|
||||
const stores = ref<StoreInfo[]>([])
|
||||
const loading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
async function load(): Promise<void> {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
stores.value = await fetchStores()
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : String(e)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function openStore(name: string): void {
|
||||
router.push({ name: ROUTES.STORE_DETAIL, params: { store: name } })
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.stores-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.stores-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 20px 24px 16px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.stores-title {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.loading-state {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 60px;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border: 2px solid var(--border-light);
|
||||
border-top-color: var(--accent);
|
||||
border-radius: 50%;
|
||||
animation: spin 0.6s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.error-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 60px 24px;
|
||||
}
|
||||
|
||||
.error-text {
|
||||
font-size: 13px;
|
||||
color: var(--error);
|
||||
}
|
||||
|
||||
.table-wrapper {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 0 24px 24px;
|
||||
}
|
||||
|
||||
.stores-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.stores-table thead {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
background: var(--bg);
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.stores-table th {
|
||||
padding: 10px 12px;
|
||||
text-align: left;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.col-num {
|
||||
width: 100px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.stores-table td.col-num {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.store-row {
|
||||
cursor: pointer;
|
||||
border-bottom: 1px solid var(--border);
|
||||
transition: background var(--transition);
|
||||
}
|
||||
|
||||
.store-row:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.stores-table td {
|
||||
padding: 12px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.col-name {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.store-icon {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
color: var(--accent);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.store-name {
|
||||
font-weight: 500;
|
||||
font-family: 'IBM Plex Mono', monospace;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.store-type {
|
||||
font-size: 12px;
|
||||
font-family: 'IBM Plex Mono', monospace;
|
||||
color: var(--text-secondary);
|
||||
background: var(--bg-elevated);
|
||||
border: 1px solid var(--border-light);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 2px 7px;
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.status-badge--ok {
|
||||
background: rgba(16, 185, 129, 0.12);
|
||||
color: #10b981;
|
||||
}
|
||||
|
||||
.status-badge--err {
|
||||
background: rgba(239, 68, 68, 0.12);
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.error-hint {
|
||||
display: block;
|
||||
margin-top: 2px;
|
||||
font-size: 11px;
|
||||
color: var(--error);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
max-width: 260px;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
padding: 80px 24px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.empty-icon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.empty-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
padding: 7px 14px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
background: var(--bg-elevated);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
transition: all var(--transition);
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text);
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -437,6 +437,47 @@ const messages: Messages = {
|
|||
'chunks.bulkMerge': 'Fusionner la s\u00e9lection',
|
||||
'chunks.bulkCancel': 'Annuler',
|
||||
|
||||
// Stores list (#243)
|
||||
'stores.title': 'Stores',
|
||||
'stores.empty': 'Aucun store configur\u00e9.',
|
||||
'stores.colName': 'Nom',
|
||||
'stores.colType': 'Type',
|
||||
'stores.colStatus': 'Statut',
|
||||
'stores.colDocs': 'Documents',
|
||||
'stores.colChunks': 'Chunks',
|
||||
'stores.connected': 'Connect\u00e9',
|
||||
'stores.disconnected': 'D\u00e9connect\u00e9',
|
||||
'stores.error': 'Erreur',
|
||||
|
||||
// Store detail (#244)
|
||||
'storeDetail.back': 'Stores',
|
||||
'storeDetail.query': 'Requ\u00eater',
|
||||
'storeDetail.empty': 'Aucun document dans ce store.',
|
||||
'storeDetail.colDoc': 'Document',
|
||||
'storeDetail.colState': '\u00c9tat',
|
||||
'storeDetail.colChunks': 'Chunks',
|
||||
'storeDetail.colIngested': 'Ing\u00e9r\u00e9 le',
|
||||
'storeDetail.remove': 'Retirer',
|
||||
'storeDetail.removeConfirm': 'Retirer \u00ab\u00a0{doc}\u00a0\u00bb du store\u00a0?',
|
||||
'storeDetail.selected': '{n} s\u00e9lectionn\u00e9(s)',
|
||||
'storeDetail.bulkRemove': 'Retirer du store',
|
||||
'storeDetail.bulkCancel': 'Annuler',
|
||||
'storeDetail.bulkConfirm': 'Retirer {n} document(s) du store\u00a0?',
|
||||
|
||||
// Store query (#245)
|
||||
'storeQuery.back': 'D\u00e9tail du store',
|
||||
'storeQuery.queryLabel': 'Question',
|
||||
'storeQuery.queryPlaceholder': 'Votre question\u2026',
|
||||
'storeQuery.topKLabel': 'R\u00e9sultats (top-k)',
|
||||
'storeQuery.run': 'Rechercher',
|
||||
'storeQuery.running': 'Recherche\u2026',
|
||||
'storeQuery.empty': 'Aucun r\u00e9sultat.',
|
||||
'storeQuery.colScore': 'Score',
|
||||
'storeQuery.colDoc': 'Document',
|
||||
'storeQuery.colText': 'Extrait',
|
||||
'storeQuery.colPage': 'Pages',
|
||||
'storeQuery.viewDoc': 'Voir',
|
||||
|
||||
// Disclaimer
|
||||
'disclaimer.banner':
|
||||
'Instance de d\u00e9monstration \u2014 les documents upload\u00e9s sont partag\u00e9s et temporaires (max {n} Mo). Ne pas envoyer de fichiers confidentiels.',
|
||||
|
|
@ -855,6 +896,47 @@ const messages: Messages = {
|
|||
'chunks.bulkMerge': 'Merge selected',
|
||||
'chunks.bulkCancel': 'Cancel',
|
||||
|
||||
// Stores list (#243)
|
||||
'stores.title': 'Stores',
|
||||
'stores.empty': 'No stores configured.',
|
||||
'stores.colName': 'Name',
|
||||
'stores.colType': 'Type',
|
||||
'stores.colStatus': 'Status',
|
||||
'stores.colDocs': 'Documents',
|
||||
'stores.colChunks': 'Chunks',
|
||||
'stores.connected': 'Connected',
|
||||
'stores.disconnected': 'Disconnected',
|
||||
'stores.error': 'Error',
|
||||
|
||||
// Store detail (#244)
|
||||
'storeDetail.back': 'Stores',
|
||||
'storeDetail.query': 'Query',
|
||||
'storeDetail.empty': 'No documents in this store.',
|
||||
'storeDetail.colDoc': 'Document',
|
||||
'storeDetail.colState': 'State',
|
||||
'storeDetail.colChunks': 'Chunks',
|
||||
'storeDetail.colIngested': 'Ingested',
|
||||
'storeDetail.remove': 'Remove',
|
||||
'storeDetail.removeConfirm': 'Remove \u00ab{doc}\u00bb from store?',
|
||||
'storeDetail.selected': '{n} selected',
|
||||
'storeDetail.bulkRemove': 'Remove from store',
|
||||
'storeDetail.bulkCancel': 'Cancel',
|
||||
'storeDetail.bulkConfirm': 'Remove {n} document(s) from store?',
|
||||
|
||||
// Store query (#245)
|
||||
'storeQuery.back': 'Store detail',
|
||||
'storeQuery.queryLabel': 'Question',
|
||||
'storeQuery.queryPlaceholder': 'Your question\u2026',
|
||||
'storeQuery.topKLabel': 'Results (top-k)',
|
||||
'storeQuery.run': 'Search',
|
||||
'storeQuery.running': 'Searching\u2026',
|
||||
'storeQuery.empty': 'No results.',
|
||||
'storeQuery.colScore': 'Score',
|
||||
'storeQuery.colDoc': 'Document',
|
||||
'storeQuery.colText': 'Excerpt',
|
||||
'storeQuery.colPage': 'Pages',
|
||||
'storeQuery.viewDoc': 'View',
|
||||
|
||||
// Disclaimer
|
||||
'disclaimer.banner':
|
||||
'Demo instance \u2014 uploaded documents are shared and temporary (max {n} MB). Do not upload confidential files.',
|
||||
|
|
|
|||
Loading…
Reference in a new issue