RSS Git Download  Clone
Raw Blame History 13kB 241 lines
<script setup lang="ts">
/**
 * Vectorset key type renderer — exact port of React KeyVectorset.tsx.
 * Info display, element list with paging, similarity search (by element / by vector).
 */
import { ref, computed, watch, onMounted, onUnmounted } from 'vue'
import P3xrAccordion from '../../../components/P3xrAccordion.vue'
import P3xrButton from '../../../components/P3xrButton.vue'
import KeyPagerInline from './KeyPagerInline.vue'
import { useI18nStore } from '../../../stores/i18n'
import { useRedisStateStore } from '../../../stores/redis-state'
import { useCommonStore } from '../../../stores/common'
import { request } from '../../../stores/socket.service'
import { type Paging, createPaging, rePaging, str } from './key-type-base'
import { parseRedisVersion } from '../../../../core/redis-version'

const props = defineProps<{ response: any; value: any; valueBuffer: any; keyName: string; valueFormat: string }>()
const emit = defineEmits<{ refresh: [] }>()

const strings = computed(() => useI18nStore().strings)
const state = useRedisStateStore()
const isReadonly = computed(() => state.connection?.readonly === true)
const common = useCommonStore()

const elements = ref<any[]>([])
const paging = ref<Paging>(createPaging(0))
const simResults = ref<Array<{ element: string; score: number }>>([])
const autoRefresh = ref(false)
const elementInput = ref('')
const vectorInput = ref('')
const simCountInput = ref(10)
const simFilterInput = ref('')
const simSearchInput = ref('')
const showAddForm = ref(false)
let autoRefreshTimer: any = null

const supportsFilter = computed(() => parseRedisVersion(state.info?.server?.redis_version).isAtLeast(8, 2))

const infoItems = computed(() => {
    try {
        let info: any
        if (typeof props.value === 'object' && props.value !== null && !ArrayBuffer.isView(props.value)) info = props.value
        else if (typeof props.value === 'string') info = JSON.parse(props.value)
        else if (ArrayBuffer.isView(props.value)) info = JSON.parse(new TextDecoder().decode(props.value))
        if (info && typeof info === 'object') return Object.entries(info).map(([key, value]) => ({ key, value }))
    } catch {}
    return []
})

onMounted(() => loadElements())
onUnmounted(() => clearInterval(autoRefreshTimer))

watch(autoRefresh, (v) => {
    clearInterval(autoRefreshTimer)
    if (v) autoRefreshTimer = setInterval(() => { emit('refresh'); loadElements() }, 10000)
})

async function loadElements() {
    try {
        const resp: any = await request({ action: 'vectorset/elements', payload: { key: props.keyName } })
        elements.value = resp.elements || []
        paging.value = rePaging(paging.value, elements.value.length)
    } catch { elements.value = [] }
}

function updatePaging(p: Paging) { paging.value = p }

async function searchByElement() {
    if (!simSearchInput.value.trim()) return
    try {
        const resp: any = await request({ action: 'vectorset/sim', payload: { key: props.keyName, mode: 'element', element: simSearchInput.value.trim(), count: simCountInput.value, filter: simFilterInput.value.trim() || undefined } })
        simResults.value = resp.results || []
        common.toast(str(strings.value?.page?.key?.vectorset?.searchComplete))
    } catch (e: any) { common.toast(e.message) }
}

async function searchByVector() {
    if (!simSearchInput.value.trim()) return
    try {
        const values = simSearchInput.value.split(',').map(Number)
        const resp: any = await request({ action: 'vectorset/sim', payload: { key: props.keyName, mode: 'vector', values, count: simCountInput.value, filter: simFilterInput.value.trim() || undefined } })
        simResults.value = resp.results || []
        common.toast(str(strings.value?.page?.key?.vectorset?.searchComplete))
    } catch (e: any) { common.toast(e.message) }
}

async function searchByElementDirect(element: string) {
    try {
        const resp: any = await request({ action: 'vectorset/sim', payload: { key: props.keyName, mode: 'element', element, count: simCountInput.value } })
        simResults.value = resp.results || []
        common.toast(str(strings.value?.page?.key?.vectorset?.searchComplete))
    } catch (e: any) { common.toast(e.message) }
}

async function getAttributes(element: string) {
    try {
        const resp: any = await request({ action: 'vectorset/getattr', payload: { key: props.keyName, element } })
        const attrs = resp.attributes
        common.toast(attrs && Object.keys(attrs).length > 0
            ? `${element}: ${JSON.stringify(attrs)}`
            : `${element}: ${str(strings.value?.page?.key?.vectorset?.noAttributes)}`)
    } catch (e: any) { if (e?.message) common.toast(e.message) }
}

async function addElement() {
    if (!elementInput.value.trim() || !vectorInput.value.trim()) return
    try {
        await request({ action: 'vectorset/add', payload: { key: props.keyName, element: elementInput.value.trim(), values: vectorInput.value.split(',').map(Number) } })
        common.toast(str(strings.value?.page?.key?.vectorset?.addedSuccessfully))
        elementInput.value = ''; vectorInput.value = ''
        emit('refresh'); loadElements()
    } catch (e: any) { common.toast(e.message) }
}

async function removeElement(element: string) {
    try {
        await common.confirm({ message: str(strings.value?.confirm?.delete) })
        await request({ action: 'vectorset/remove', payload: { key: props.keyName, element } })
        common.toast(str(strings.value?.page?.key?.vectorset?.deletedSuccessfully))
        emit('refresh'); loadElements()
    } catch (e: any) { if (e?.message) common.toast(e.message) }
}
</script>

<template>
    <div class="p3xr-key-type-content">
        <!-- INFO -->
        <br />
        <P3xrAccordion :title="str(strings?.page?.key?.vectorset?.info)" accordion-key="vs-info">
            <template #actions>
                <P3xrButton :icon="autoRefresh ? 'mdi-checkbox-marked' : 'mdi-checkbox-blank-outline'" :label="str(strings?.label?.autoRefresh)" :breakpoint="1280" color="inherit" @click.stop="autoRefresh = !autoRefresh" />
                <P3xrButton v-if="!autoRefresh" icon="mdi-refresh" :label="str(strings?.intention?.refresh)" :breakpoint="1280" color="inherit" @click.stop="emit('refresh'); loadElements()" />
            </template>
            <v-list density="compact">
                <template v-for="(item, i) in infoItems" :key="item.key">
                    <v-list-item>
                        <div style="display: flex; width: 100%;">
                            <span style="flex: 1; font-weight: 500;">{{ item.key }}</span>
                            <span style="word-break: break-all;">{{ String(item.value) }}</span>
                        </div>
                    </v-list-item>
                    <v-divider v-if="i < infoItems.length - 1" />
                </template>
            </v-list>
        </P3xrAccordion>

        <!-- ELEMENTS -->
        <br />
        <P3xrAccordion :title="str(strings?.page?.key?.vectorset?.elements) + ` (${elements.length})`" accordion-key="vs-elements">
            <KeyPagerInline :paging="paging" @page-changed="updatePaging" />
            <div class="p3xr-key-table-header">
                <span style="flex: 50%;">{{ str(strings?.page?.key?.vectorset?.element) }}</span>
                <span style="flex: 20%;">{{ str(strings?.page?.key?.vectorset?.score) }}</span>
                <span style="flex: 30%; text-align: right; display: flex; justify-content: flex-end; align-items: center;">
                    <v-tooltip v-if="!isReadonly" :text="str(strings?.intention?.add)" location="top">
                        <template #activator="{ props: tp }"><v-icon v-bind="tp" style="cursor:pointer;color:inherit;" @click="showAddForm = !showAddForm">mdi-plus</v-icon></template>
                    </v-tooltip>
                </span>
            </div>
            <div v-for="(elem, i) in elements.slice(paging.startIndex, paging.startIndex + paging.pageCount)" :key="elem.element"
                class="p3xr-vs-row" :class="{ 'p3xr-vs-odd': i % 2 === 0 }">
                <span style="flex: 50%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">{{ elem.element }}</span>
                <span style="flex: 20%; font-family: 'Roboto Mono', monospace;">{{ elem.score?.toFixed(4) }}</span>
                <span style="flex: 30%; text-align: right; white-space: nowrap;">
                    <v-tooltip :text="'Find similar'" location="top"><template #activator="{ props: tp }">
                        <v-icon v-bind="tp" size="24" class="p3xr-key-icon" style="color:rgb(var(--v-theme-secondary));" @click="simSearchInput = elem.element; searchByElementDirect(elem.element)">mdi-magnify</v-icon>
                    </template></v-tooltip>
                    <v-tooltip :text="str(strings?.page?.key?.vectorset?.attributes)" location="top"><template #activator="{ props: tp }">
                        <v-icon v-bind="tp" size="24" class="p3xr-key-icon" style="color:rgb(var(--v-theme-secondary));" @click="getAttributes(elem.element)">mdi-information</v-icon>
                    </template></v-tooltip>
                    <v-tooltip v-if="!isReadonly" :text="str(strings?.intention?.delete)" location="top"><template #activator="{ props: tp }">
                        <v-icon v-bind="tp" size="24" class="p3xr-key-icon" style="color:rgb(var(--v-theme-error));" @click="removeElement(elem.element)">mdi-delete</v-icon>
                    </template></v-tooltip>
                </span>
            </div>
            <!-- Add form -->
            <div v-if="!isReadonly && showAddForm" style="padding: 16px;">
                <div class="p3xr-vs-controls">
                    <v-text-field density="compact" variant="outlined" hide-details style="flex: 1; min-width: 200px;"
                        :label="str(strings?.page?.key?.vectorset?.elementName)"
                        v-model="elementInput" @keyup.enter="addElement()" />
                    <v-text-field density="compact" variant="outlined" hide-details style="flex: 1; min-width: 200px;"
                        :label="str(strings?.page?.key?.vectorset?.vectorValues)" placeholder="0.1, 0.2, 0.3"
                        v-model="vectorInput" @keyup.enter="addElement()" />
                    <P3xrButton icon="mdi-plus" :label="str(strings?.intention?.add)" raised color="primary" @click="addElement()" />
                </div>
            </div>
        </P3xrAccordion>

        <!-- SIMILARITY SEARCH -->
        <br />
        <P3xrAccordion :title="str(strings?.page?.key?.vectorset?.similaritySearch)" accordion-key="vs-sim">
            <div style="padding: 16px;">
                <div class="p3xr-vs-controls">
                    <v-text-field density="compact" variant="outlined" hide-details style="flex: 1; min-width: 200px;"
                        :label="str(strings?.page?.key?.vectorset?.searchTerm)"
                        v-model="simSearchInput" @keyup.enter="searchByElement()" />
                    <v-text-field density="compact" variant="outlined" hide-details type="number" style="width: 80px; max-width: 80px;"
                        :label="str(strings?.page?.key?.vectorset?.count)"
                        v-model.number="simCountInput" />
                    <v-text-field v-if="supportsFilter" density="compact" variant="outlined" hide-details style="flex: 1; min-width: 150px;"
                        :label="str(strings?.page?.key?.vectorset?.filter)" placeholder="attr == 'value'"
                        v-model="simFilterInput" @keyup.enter="searchByElement()" />
                    <P3xrButton icon="mdi-account" :label="str(strings?.page?.key?.vectorset?.byElement)" raised color="primary" @click="searchByElement()" />
                    <P3xrButton icon="mdi-code-array" :label="str(strings?.page?.key?.vectorset?.byVector)" raised color="primary" @click="searchByVector()" />
                </div>
                <template v-if="simResults.length > 0">
                    <v-divider class="my-2" />
                    <v-list density="compact">
                        <template v-for="(entry, i) in simResults" :key="i">
                            <v-list-item>
                                <div style="display: flex; width: 100%;">
                                    <span style="flex: 1; font-weight: 500;">{{ entry.element }}</span>
                                    <span>{{ entry.score }}</span>
                                </div>
                            </v-list-item>
                            <v-divider v-if="i < simResults.length - 1" />
                        </template>
                    </v-list>
                </template>
            </div>
        </P3xrAccordion>
    </div>
</template>

<style scoped>
.p3xr-key-type-content { padding: 8px 16px 24px; }
.p3xr-vs-controls { display: flex; flex-wrap: wrap; align-items: flex-start; gap: 12px; padding: 8px 0; }
.p3xr-key-table-header {
    display: flex; align-items: center; gap: 8px; padding: 8px 16px; font-weight: bold;
    background-color: rgb(var(--v-theme-primary)); color: rgb(var(--v-theme-on-primary));
    border-bottom: 2px solid rgba(var(--v-theme-on-surface), 0.05);
}
.p3xr-vs-row {
    display: flex; align-items: center; gap: 8px; padding: 6px 16px;
    border-bottom: 1px solid rgba(var(--v-theme-on-surface), 0.05);
}
.p3xr-vs-row:hover { background-color: rgba(var(--v-theme-on-surface), 0.1) !important; }
.p3xr-vs-odd { background-color: rgba(var(--v-theme-on-surface), 0.04); }
</style>