RSS Git Download  Clone
Raw Blame History 18kB 374 lines
<script setup lang="ts">
import { ref, computed, watch, onMounted } from 'vue'
import { useDisplay } from 'vuetify'
import P3xrAccordion from '../../components/P3xrAccordion.vue'
import P3xrButton from '../../components/P3xrButton.vue'
import { useI18nStore } from '../../stores/i18n'
import { useRedisStateStore } from '../../stores/redis-state'
import { useCommonStore } from '../../stores/common'
import { useOverlayStore } from '../../stores/overlay'
import { request } from '../../stores/socket.service'
import { parseRedisVersion } from '../../../core/redis-version'

const i18n = useI18nStore()
const state = useRedisStateStore()
const common = useCommonStore()
const overlay = useOverlayStore()
const { width: displayWidth } = useDisplay()
const isGtSm = computed(() => displayWidth.value >= 960)

const strings = computed(() => i18n.strings)
const s = computed(() => strings.value?.page?.search || {} as any)
const isReadonly = computed(() => state.connection?.readonly === true)

const indexes = ref<string[]>([])
const selectedIndex = ref('')
const query = ref('*')
const offset = ref(0)
const limit = 20
const total = ref(0)
const results = ref<any[]>([])
const indexInfo = ref<any>(null)
const searchDone = ref(false)
const aiLoading = ref(false)

// Hybrid search
const hybridMode = ref(false)
const vectorField = ref('')
const vectorValues = ref('')
const vectorCount = ref(10)

// Create index
const newIndexName = ref('')
const newIndexPrefix = ref('')
const newIndexFields = ref<{ name: string; type: string; sortable: boolean }[]>([{ name: '', type: 'TEXT', sortable: false }])

const pages = computed(() => Math.ceil(total.value / limit))
const currentPage = computed(() => Math.floor(offset.value / limit) + 1)

function getDocKeys(doc: any) { return Object.keys(doc).filter(k => k !== '_key') }

async function loadIndexes() {
    try {
        const resp = await request({ action: 'search/list', payload: {} })
        indexes.value = resp.data
        return resp.data as string[]
    } catch { return [] }
}

async function loadIndexInfo(idx?: string) {
    const index = idx || selectedIndex.value
    if (!index) return
    try {
        const resp = await request({ action: 'search/index-info', payload: { index } })
        indexInfo.value = resp.data
    } catch (e) { common.generalHandleError(e) }
}

async function doSearch(off?: number) {
    if (!selectedIndex.value || !query.value) return
    try {
        let resp: any
        if (hybridMode.value && vectorField.value && vectorValues.value) {
            const values = vectorValues.value.split(',').map(v => parseFloat(v.trim())).filter(v => !isNaN(v))
            resp = await request({
                action: 'search/hybrid',
                payload: { index: selectedIndex.value, query: query.value, vectorField: vectorField.value, vectorValues: values, count: vectorCount.value, offset: off ?? offset.value, limit },
            })
        } else {
            resp = await request({
                action: 'search/query',
                payload: { index: selectedIndex.value, query: query.value, offset: off ?? offset.value, limit },
            })
        }
        total.value = resp.data.total
        results.value = resp.data.docs
    } catch (e) {
        common.generalHandleError(e)
        results.value = []; total.value = 0
    } finally { searchDone.value = true }
}

async function handleAiQuery(prompt: string) {
    if (!prompt) return
    aiLoading.value = true
    try {
        const resp = await request({
            action: 'ai/redis-query',
            payload: { prompt, context: { indexes: indexes.value, schema: indexInfo.value } },
        })
        query.value = resp.command
        if (resp.explanation) common.toast(resp.explanation)
        offset.value = 0
        const sr = await request({ action: 'search/query', payload: { index: selectedIndex.value, query: resp.command, offset: 0, limit } })
        total.value = sr.data.total; results.value = sr.data.docs; searchDone.value = true
        await loadIndexInfo()
    } catch (e) { common.generalHandleError(e) }
    finally { aiLoading.value = false }
}

async function handleSearchEnter() {
    const q = (query.value || '').trim()
    if (/^ai:\s*/i.test(q)) { await handleAiQuery(q.replace(/^ai:\s*/i, '').trim()); return }
    try {
        await Promise.all([doSearch(0), loadIndexInfo()])
    } catch {
        if (q.length > 2 && q !== '*' && /\s/.test(q)) {
            overlay.show()
            try { await handleAiQuery(q) } finally { overlay.hide() }
        }
    }
}

function pageAction(action: string) {
    let newOffset = offset.value
    switch (action) {
        case 'first': newOffset = 0; break
        case 'prev': newOffset = Math.max(0, offset.value - limit); break
        case 'next': newOffset = Math.min((pages.value - 1) * limit, offset.value + limit); break
        case 'last': newOffset = (pages.value - 1) * limit; break
    }
    offset.value = newOffset
    doSearch(newOffset)
}

async function dropIndex() {
    if (!selectedIndex.value) return
    try {
        await common.confirm({ message: strings.value?.confirm?.dropIndex })
        await request({ action: 'search/index-drop', payload: { index: selectedIndex.value } })
        common.toast(strings.value?.status?.indexDropped)
        selectedIndex.value = ''; results.value = []; total.value = 0; searchDone.value = false; indexInfo.value = null
        await loadIndexes()
    } catch (e: any) { if (e !== undefined) common.generalHandleError(e) }
}

function addField() { newIndexFields.value.push({ name: '', type: 'TEXT', sortable: false }) }

async function confirmRemoveField(index: number) {
    try {
        await common.confirm({ message: strings.value?.intention?.delete + '?' })
        newIndexFields.value.splice(index, 1)
    } catch (e: any) { if (e !== undefined) common.generalHandleError(e) }
}

async function createIndex() {
    if (!newIndexName.value.trim()) return
    const schema = newIndexFields.value.filter(f => f.name.trim())
    if (schema.length === 0) return
    try {
        await request({
            action: 'search/index-create',
            payload: { name: newIndexName.value.trim(), prefix: newIndexPrefix.value.trim() || undefined, schema },
        })
        common.toast(strings.value?.status?.indexCreated)
        newIndexName.value = ''; newIndexPrefix.value = ''
        newIndexFields.value = [{ name: '', type: 'TEXT', sortable: false }]
        await loadIndexes()
    } catch (e) { common.generalHandleError(e) }
}

function onIndexChange(idx: string) {
    selectedIndex.value = idx
    offset.value = 0; indexInfo.value = null
    loadIndexInfo(idx)
}

// Init on connection change
watch(() => state.connection?.id, () => {
    selectedIndex.value = ''; results.value = []; total.value = 0
    searchDone.value = false; indexInfo.value = null
    loadIndexes().then(idxs => {
        if (idxs.length > 0) { selectedIndex.value = idxs[0]; loadIndexInfo(idxs[0]) }
    })
}, { immediate: true })

const supportsHybrid = computed(() => {
    const ver = state.info?.server?.redis_version
    return ver ? parseRedisVersion(ver).isAtLeast(8, 4) : false
})
</script>

<template>
    <div>
        <!-- Search Query -->
        <P3xrAccordion :title="s.title" accordion-key="search-query">
            <div style="padding: 16px;">
                <div v-if="indexes.length === 0" style="opacity: 0.5;">{{ s.noIndex }}</div>
                <template v-if="indexes.length > 0">
                    <div style="display: flex; align-items: center; gap: 8px;">
                        <v-select v-model="selectedIndex" :items="indexes" :label="s.index"
                            density="compact" variant="outlined" hide-details @update:model-value="onIndexChange" />
                        <v-tooltip v-if="!isReadonly && selectedIndex" :text="s.dropIndex" location="top">
                            <template #activator="{ props: tp }">
                                <v-btn v-bind="tp" color="error" variant="flat"
                                    style="min-width: 40px; width: 40px; height: 40px; padding: 0; border-radius: 4px;"
                                    @click="dropIndex">
                                    <v-icon size="small">mdi-delete</v-icon>
                                </v-btn>
                            </template>
                        </v-tooltip>
                    </div>
                    <v-text-field v-model="query" :label="s.query" density="compact" variant="outlined"
                        hide-details :disabled="aiLoading" style="margin-top: 8px;"
                        @keydown.enter="offset = 0; handleSearchEnter()" />

                    <!-- Hybrid search (Redis 8.4+) -->
                    <template v-if="supportsHybrid">
                        <v-switch v-model="hybridMode" :label="s.hybridMode"
                            color="primary" style="margin-top: 8px;" />
                        <div v-if="hybridMode" style="display: flex; gap: 8px; flex-wrap: wrap; margin-top: 8px;">
                            <v-text-field v-model="vectorField" :label="s.vectorField" placeholder="embedding"
                                density="compact" variant="outlined" hide-details style="flex: 1; min-width: 150px;" />
                            <v-text-field v-model="vectorValues" :label="s.vectorValues" placeholder="0.1, 0.2, 0.3, ..."
                                density="compact" variant="outlined" hide-details style="flex: 2; min-width: 200px;" />
                            <v-text-field v-model.number="vectorCount" label="Count" type="number"
                                density="compact" variant="outlined" hide-details style="width: 80px; flex: none;" />
                        </div>
                    </template>

                    <div style="margin-top: 8px; text-align: right;">
                        <v-btn v-if="isGtSm" color="primary" variant="flat" size="small" :disabled="aiLoading"
                            style="gap: 3px;" @click="offset = 0; handleSearchEnter()">
                            <v-icon size="small">mdi-magnify</v-icon>
                            <span>{{ aiLoading ? strings?.label?.aiTranslating : s.title }}</span>
                        </v-btn>
                        <v-tooltip v-else :text="s.title" location="top">
                            <template #activator="{ props: tp }">
                                <v-btn v-bind="tp" color="primary" variant="flat" :disabled="aiLoading"
                                    style="min-width: 40px; width: 40px; height: 40px; padding: 0; border-radius: 4px;"
                                    @click="offset = 0; handleSearchEnter()">
                                    <v-icon size="small">mdi-magnify</v-icon>
                                </v-btn>
                            </template>
                        </v-tooltip>
                    </div>
                </template>
            </div>
        </P3xrAccordion>

        <!-- Results - empty -->
        <template v-if="searchDone && total === 0">
            <div style="margin-top: 8px;" />
            <P3xrAccordion :title="`${s.results} (0)`" accordion-key="search-results">
                <div style="padding: 16px; opacity: 0.5;">{{ strings?.label?.noResults }}</div>
            </P3xrAccordion>
        </template>

        <!-- Results - with data -->
        <template v-if="results.length > 0 || total > 0">
            <div style="margin-top: 8px;" />
            <P3xrAccordion :title="`${s.results} (${total})`" accordion-key="search-results">
                <template v-if="pages > 1" #actions>
                    <P3xrButton icon="mdi-skip-previous" label="" color="inherit" @click.stop="pageAction('first')" />
                    <P3xrButton icon="mdi-chevron-left" label="" color="inherit" @click.stop="pageAction('prev')" />
                    <span style="font-size: 12px; opacity: 0.7;">{{ currentPage }} / {{ pages }}</span>
                    <P3xrButton icon="mdi-chevron-right" label="" color="inherit" @click.stop="pageAction('next')" />
                    <P3xrButton icon="mdi-skip-next" label="" color="inherit" @click.stop="pageAction('last')" />
                </template>
                <v-list density="compact" class="pa-0">
                    <template v-for="doc in results" :key="doc._key">
                        <v-list-item style="padding: 8px 16px;">
                            <div style="display: flex; width: 100%; align-items: center;">
                                <div style="flex: 1;">
                                    <kbd style="padding: 2px 6px; border-radius: 4px; font-size: 11px; background: rgba(128,128,128,0.1); font-family: 'Roboto Mono', monospace;">{{ doc._key }}</kbd>
                                </div>
                                <div style="font-family: 'Roboto Mono', monospace; font-size: 12px;">
                                    <template v-for="(field, i) in getDocKeys(doc)" :key="field">
                                        {{ field }}: {{ doc[field] }}<template v-if="i < getDocKeys(doc).length - 1"> &middot; </template>
                                    </template>
                                </div>
                            </div>
                        </v-list-item>
                        <v-divider />
                    </template>
                </v-list>
            </P3xrAccordion>
        </template>

        <!-- Index Info -->
        <template v-if="selectedIndex && indexInfo">
            <div style="margin-top: 8px;" />
            <P3xrAccordion :title="`${s.indexInfo}: ${selectedIndex}`" accordion-key="search-index-info">
                <template v-if="!isReadonly" #actions>
                    <P3xrButton icon="mdi-delete" :label="s.dropIndex" color="inherit" @click.stop="dropIndex" />
                </template>
                <v-list density="compact" class="pa-0">
                    <template v-for="key in getDocKeys(indexInfo)" :key="key">
                        <v-list-item style="padding: 8px 16px;">
                            <div style="display: flex; width: 100%;">
                                <div style="flex: 1;">{{ key }}</div>
                                <div style="font-family: 'Roboto Mono', monospace; font-size: 12px;">{{ JSON.stringify(indexInfo[key]) }}</div>
                            </div>
                        </v-list-item>
                        <v-divider />
                    </template>
                </v-list>
            </P3xrAccordion>
        </template>

        <!-- Create Index -->
        <template v-if="!isReadonly">
            <div style="margin-top: 8px;" />
            <P3xrAccordion :title="s.createIndex" accordion-key="search-create-index">
                <div style="padding: 16px;">
                    <v-text-field v-model="newIndexName" :label="s.indexName"
                        density="compact" variant="outlined" hide-details />
                    <v-text-field v-model="newIndexPrefix" :label="s.prefix" placeholder="e.g. doc:"
                        density="compact" variant="outlined" hide-details style="margin-top: 8px;" />

                    <div style="display: flex; align-items: center; gap: 8px; margin: 8px 0;">
                        <strong>Schema</strong>
                        <v-tooltip text="Add" location="top">
                            <template #activator="{ props: tp }">
                                <v-btn v-bind="tp" color="primary" variant="flat"
                                    style="min-width: 40px; width: 40px; height: 40px; padding: 0; border-radius: 4px;"
                                    @click="addField">
                                    <v-icon size="small">mdi-plus</v-icon>
                                </v-btn>
                            </template>
                        </v-tooltip>
                    </div>

                    <div v-for="(field, i) in newIndexFields" :key="i" style="display: flex; align-items: center; gap: 8px; margin-bottom: 4px; flex-wrap: wrap;">
                        <v-text-field v-model="field.name" :label="s.fieldName"
                            density="compact" variant="outlined" hide-details style="flex: 1; min-width: 120px;" />
                        <div style="display: flex; align-items: center; gap: 8px; flex-shrink: 0;">
                            <v-select v-model="field.type" :items="['TEXT', 'NUMERIC', 'TAG', 'GEO', 'VECTOR']"
                                :label="strings?.label?.type"
                                density="compact" variant="outlined" hide-details style="width: 130px;" />
                            <v-tooltip :text="strings?.intention?.delete" location="top">
                                <template #activator="{ props: tp }">
                                    <v-btn v-bind="tp" color="error" variant="flat" :disabled="newIndexFields.length <= 1"
                                        style="min-width: 40px; width: 40px; height: 40px; padding: 0; border-radius: 4px;"
                                        @click="confirmRemoveField(i)">
                                        <v-icon size="small">mdi-minus</v-icon>
                                    </v-btn>
                                </template>
                            </v-tooltip>
                        </div>
                    </div>

                    <div style="margin-top: 8px; text-align: right;">
                        <v-btn v-if="isGtSm" color="secondary" variant="flat" size="small" :disabled="!newIndexName.trim()"
                            style="gap: 3px;" @click="createIndex">
                            <v-icon size="small">mdi-plus</v-icon>
                            <span>{{ s.createIndex }}</span>
                        </v-btn>
                        <v-tooltip v-else :text="s.createIndex" location="top">
                            <template #activator="{ props: tp }">
                                <v-btn v-bind="tp" color="secondary" variant="flat" :disabled="!newIndexName.trim()"
                                    style="min-width: 40px; width: 40px; height: 40px; padding: 0; border-radius: 4px;"
                                    @click="createIndex">
                                    <v-icon size="small">mdi-plus</v-icon>
                                </v-btn>
                            </template>
                        </v-tooltip>
                    </div>
                </div>
            </P3xrAccordion>
        </template>

    </div>
</template>