| <template> |
| <div class="retrieval-tab"> |
| <v-card elevation="2"> |
| <v-card-title class="pa-4 pb-0">{{ t('retrieval.title') }}</v-card-title> |
| <v-card-subtitle class="pb-4 pt-2"> |
| {{ t('retrieval.subtitle') }} |
| </v-card-subtitle> |
| |
| <v-divider /> |
| <v-progress-linear v-if="loading" indeterminate color="primary" height="2" /> |
| |
| <v-card-text class="pa-6"> |
| |
| <v-row class="mb-4"> |
| <v-col cols="12" md="8"> |
| <v-textarea v-model="query" :label="t('retrieval.query')" :placeholder="t('retrieval.queryPlaceholder')" |
| variant="outlined" rows="3" auto-grow clearable /> |
| |
| |
| <div v-if="debugVisualize" class="mt-2"> |
| <v-card variant="outlined"> |
| <v-img :src="`data:image/png;base64,${debugVisualize}`" :alt="t('retrieval.tsneVisualization')" cover> |
| <template v-slot:placeholder> |
| <div class="d-flex align-center justify-center fill-height"> |
| <v-progress-circular indeterminate color="primary" /> |
| </div> |
| </template> |
| </v-img> |
| </v-card> |
| </div> |
| </v-col> |
| <v-col cols="12" md="4"> |
| <v-card variant="outlined" class="pa-4"> |
| <h4 class="text-subtitle-2 mb-3">{{ t('retrieval.settings') }}</h4> |
| |
| <v-text-field v-model.number="topK" :label="t('retrieval.topK')" :hint="t('retrieval.topKHint')" |
| type="number" variant="outlined" density="compact" persistent-hint class="mb-3" /> |
| |
| <v-switch v-model="debugMode" :label="t('retrieval.debugMode')" color="primary" density="compact" |
| hide-details> |
| <template v-slot:label> |
| <span class="text-caption"> |
| <v-icon size="small" class="mr-1">mdi-bug</v-icon> |
| Debug (t-SNE) |
| </span> |
| </template> |
| </v-switch> |
| </v-card> |
| </v-col> |
| </v-row> |
| |
| <div class="d-flex justify-end mb-4"> |
| <v-btn prepend-icon="mdi-magnify" color="primary" variant="elevated" @click="performRetrieval" |
| :loading="loading" :disabled="!query || query.trim() === ''"> |
| {{ loading ? t('retrieval.searching') : t('retrieval.search') }} |
| </v-btn> |
| </div> |
| |
| |
| <div v-if="hasSearched" class="results-section"> |
| <v-divider class="mb-4" /> |
| |
| <div class="d-flex align-center mb-4"> |
| <h3 class="text-h6">{{ t('retrieval.results') }}</h3> |
| <v-chip class="ml-3" color="primary" variant="tonal" size="small"> |
| {{ results.length }} {{ t('retrieval.results') }} |
| </v-chip> |
| </div> |
| |
| |
| <div v-if="results.length > 0" class="results-list"> |
| <v-card v-for="(result, index) in results" :key="result.chunk_id" variant="outlined" class="mb-4"> |
| <v-card-title class="d-flex align-center pa-2"> |
| <v-chip size="x-small" color="primary" class="mr-2"> |
| #{{ index + 1 }} |
| </v-chip> |
| <span class="text-subtitle-1"> |
| {{ t('retrieval.chunk', { index: result.chunk_index }) }} |
| </span> |
| <div class="ml-4"> |
| <v-chip size="x-small" variant="tonal" class="mr-2"> |
| <v-icon start size="small">mdi-file-document</v-icon> |
| {{ result.doc_name }} |
| </v-chip> |
| <v-chip size="x-small" variant="tonal"> |
| <v-icon start size="small">mdi-text</v-icon> |
| {{ t('retrieval.charCount', { count: result.char_count }) }} |
| </v-chip> |
| </div> |
| <v-spacer /> |
| <v-chip size="x-small" :color="getScoreColor(result.score)"> |
| {{ t('retrieval.score') }}: {{ result.score.toFixed(4) }} |
| </v-chip> |
| </v-card-title> |
| |
| <v-divider /> |
| |
| <v-card-text class="pa-4"> |
| <div class="content-box"> |
| {{ result.content }} |
| </div> |
| </v-card-text> |
| </v-card> |
| </div> |
| |
| |
| <div v-else class="text-center py-12"> |
| <v-icon size="80" color="grey-lighten-2">mdi-text-box-search-outline</v-icon> |
| <p class="text-h6 mt-4 text-medium-emphasis">{{ t('retrieval.noResults') }}</p> |
| <p class="text-body-2 text-medium-emphasis">{{ t('retrieval.tryDifferentQuery') }}</p> |
| </div> |
| </div> |
| </v-card-text> |
| </v-card> |
| |
| |
| <v-snackbar v-model="snackbar.show" :color="snackbar.color"> |
| {{ snackbar.text }} |
| </v-snackbar> |
| </div> |
| </template> |
| |
| <script setup lang="ts"> |
| import { ref } from 'vue' |
| import axios from 'axios' |
| import { useModuleI18n } from '@/i18n/composables' |
| |
| const { tm: t } = useModuleI18n('features/knowledge-base/detail') |
| |
| const props = defineProps<{ |
| kbId: string, |
| kbName: string, |
| }>() |
| |
| |
| const loading = ref(false) |
| const query = ref('') |
| const topK = ref(5) |
| const debugMode = ref(false) |
| const results = ref<any[]>([]) |
| const hasSearched = ref(false) |
| const debugVisualize = ref<string | null>(null) |
| |
| const snackbar = ref({ |
| show: false, |
| text: '', |
| color: 'success' |
| }) |
| |
| const showSnackbar = (text: string, color: string = 'success') => { |
| snackbar.value.text = text |
| snackbar.value.color = color |
| snackbar.value.show = true |
| } |
| |
| |
| const performRetrieval = async () => { |
| if (!query.value || query.value.trim() === '') { |
| showSnackbar(t('retrieval.queryRequired'), 'warning') |
| return |
| } |
| |
| loading.value = true |
| hasSearched.value = false |
| debugVisualize.value = null |
| |
| try { |
| const response = await axios.post('/api/kb/retrieve', { |
| query: query.value, |
| kb_names: [props.kbName], |
| top_k: topK.value, |
| debug: debugMode.value |
| }) |
| |
| if (response.data.status === 'ok') { |
| results.value = response.data.data.results || [] |
| hasSearched.value = true |
| |
| if (debugMode.value && response.data.data.visualization) { |
| debugVisualize.value = response.data.data.visualization |
| } |
| |
| showSnackbar(t('retrieval.searchSuccess', { count: results.value.length })) |
| } else { |
| showSnackbar(response.data.message || t('retrieval.searchFailed'), 'error') |
| } |
| } catch (error) { |
| console.error('Retrieval failed:', error) |
| showSnackbar(t('retrieval.searchFailed'), 'error') |
| } finally { |
| loading.value = false |
| } |
| } |
| |
| |
| const getScoreColor = (score: number) => { |
| if (score >= 0.8) return 'success' |
| if (score >= 0.6) return 'info' |
| if (score >= 0.4) return 'warning' |
| return 'error' |
| } |
| </script> |
| |
| <style scoped> |
| .retrieval-tab { |
| animation: fadeIn 0.3s ease; |
| } |
| |
| @keyframes fadeIn { |
| from { |
| opacity: 0; |
| } |
| |
| to { |
| opacity: 1; |
| } |
| } |
| |
| .results-section { |
| animation: slideUp 0.4s ease; |
| } |
| |
| @keyframes slideUp { |
| from { |
| opacity: 0; |
| transform: translateY(20px); |
| } |
| |
| to { |
| opacity: 1; |
| transform: translateY(0); |
| } |
| } |
| |
| .content-box { |
| background: rgba(var(--v-theme-surface-variant), 0.1); |
| border-radius: 8px; |
| padding: 16px; |
| white-space: pre-wrap; |
| word-break: break-word; |
| font-family: 'Consolas', 'Monaco', 'Courier New', monospace; |
| font-size: 0.9rem; |
| line-height: 1.6; |
| height: 120px; |
| overflow-y: auto; |
| font-size: 13px; |
| } |
| </style> |
| |