RSS Git Download  Clone
Raw Blame History 11kB 324 lines
import { Component, Inject, OnInit, OnDestroy, ChangeDetectorRef, ChangeDetectionStrategy, NgZone, ViewEncapsulation } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { MatIconModule } from '@angular/material/icon';
import { MatButtonModule } from '@angular/material/button';
import { MatTooltipModule } from '@angular/material/tooltip';
import { MatDividerModule } from '@angular/material/divider';
import { MatListModule } from '@angular/material/list';
import { MatSelectModule } from '@angular/material/select';
import { MatFormFieldModule } from '@angular/material/form-field';
import { BreakpointObserver } from '@angular/cdk/layout';
import { MatInputModule } from '@angular/material/input';
import { MatSlideToggleModule } from '@angular/material/slide-toggle';

import { I18nService } from '../../services/i18n.service';
import { SocketService } from '../../services/socket.service';
import { CommonService } from '../../services/common.service';
import { P3xrAccordionComponent } from '../../components/p3xr-accordion.component';
import { P3xrButtonComponent } from '../../components/p3xr-button.component';
import { RedisStateService } from '../../services/redis-state.service';
import { OverlayService } from '../../services/overlay.service';

@Component({
    selector: 'p3xr-search',
    standalone: true,
    imports: [
        CommonModule, FormsModule,
        MatIconModule, MatButtonModule, MatTooltipModule,
        MatDividerModule, MatListModule, MatSelectModule,
        MatFormFieldModule, MatInputModule, MatSlideToggleModule,
        P3xrAccordionComponent, P3xrButtonComponent,
    ],
    templateUrl: './search.component.html',
    styleUrls: ['./search.component.scss'],
    encapsulation: ViewEncapsulation.None,
    changeDetection: ChangeDetectionStrategy.OnPush,
})
export class SearchComponent implements OnInit, OnDestroy {
    strings;
    indexes: string[] = [];
    selectedIndex = '';
    query = '*';
    offset = 0;
    limit = 20;
    total = 0;
    results: any[] = [];
    indexInfo: any = null;
    searching = false;
    searchDone = false;
    isReadonly = false;
    isGtSm = true;

    aiLoading = false;

    // Hybrid search (FT.HYBRID, Redis 8.4+)
    hybridMode = false;
    vectorField = '';
    vectorValues = '';
    vectorCount = 10;

    // Index creation
    newIndexName = '';
    newIndexPrefix = '';
    newIndexFields: Array<{ name: string; type: string; sortable: boolean }> = [
        { name: '', type: 'TEXT', sortable: false },
    ];

    private unsubs: Array<() => void> = [];

    constructor(
        @Inject(I18nService) private i18n: I18nService,
        @Inject(SocketService) private socket: SocketService,
        @Inject(CommonService) private common: CommonService,
        @Inject(ChangeDetectorRef) private cdr: ChangeDetectorRef,
        @Inject(NgZone) private ngZone: NgZone,
        @Inject(BreakpointObserver) private breakpointObserver: BreakpointObserver,
        @Inject(RedisStateService) private state: RedisStateService,
        @Inject(OverlayService) private overlay: OverlayService,
    ) {
        this.strings = this.i18n.strings;
    }

    ngOnInit(): void {
        this.isReadonly = this.state.connection()?.readonly === true;
        const sub960 = this.breakpointObserver.observe('(min-width: 960px)').subscribe(r => {
            this.isGtSm = r.matches;
            this.cdr.markForCheck();
        });
        this.unsubs.push(() => sub960.unsubscribe());
        this.loadIndexes();

        const sub = this.socket.stateChanged$.subscribe(() => {
            this.isReadonly = this.state.connection()?.readonly === true;
            this.loadIndexes();
        });
        this.unsubs.push(() => sub.unsubscribe());
    }

    async searchAndRefreshInfo(): Promise<void> {
        await Promise.all([
            this.search(),
            this.loadIndexInfo(),
        ]);
    }

    ngOnDestroy(): void {
        this.unsubs.forEach(fn => fn());
    }

    get pages(): number {
        return Math.ceil(this.total / this.limit);
    }

    get currentPage(): number {
        return Math.floor(this.offset / this.limit) + 1;
    }

    async loadIndexes(): Promise<void> {
        try {
            const response = await this.socket.request({ action: 'search/list', payload: {} });
            this.indexes = response.data;
            if (this.indexes.length > 0 && !this.selectedIndex) {
                this.selectedIndex = this.indexes[0];
                this.loadIndexInfo();
            }
            this.cdr.markForCheck();
        } catch { /* ignore */ }
    }

    async search(): Promise<void> {
        if (!this.selectedIndex || !this.query) return;
        this.searching = true;
        this.cdr.markForCheck();
        try {
            let response: any;
            if (this.hybridMode && this.vectorField && this.vectorValues) {
                const values = this.vectorValues.split(',').map((v: string) => parseFloat(v.trim())).filter((v: number) => !isNaN(v));
                response = await this.socket.request({
                    action: 'search/hybrid',
                    payload: {
                        index: this.selectedIndex,
                        query: this.query,
                        vectorField: this.vectorField,
                        vectorValues: values,
                        count: this.vectorCount,
                        offset: this.offset,
                        limit: this.limit,
                    },
                });
            } else {
                response = await this.socket.request({
                    action: 'search/query',
                    payload: {
                        index: this.selectedIndex,
                        query: this.query,
                        offset: this.offset,
                        limit: this.limit,
                    },
                });
            }
            this.total = response.data.total;
            this.results = response.data.docs;
        } catch (e) {
            this.common.generalHandleError(e);
            this.results = [];
            this.total = 0;
        } finally {
            this.searching = false;
            this.searchDone = true;
            this.cdr.markForCheck();
        }
    }

    pageAction(action: string): void {
        switch (action) {
            case 'first': this.offset = 0; break;
            case 'prev': this.offset = Math.max(0, this.offset - this.limit); break;
            case 'next': this.offset = Math.min((this.pages - 1) * this.limit, this.offset + this.limit); break;
            case 'last': this.offset = (this.pages - 1) * this.limit; break;
        }
        this.search();
    }

    async loadIndexInfo(): Promise<void> {
        if (!this.selectedIndex) return;
        try {
            const response = await this.socket.request({
                action: 'search/index-info',
                payload: { index: this.selectedIndex },
            });
            this.indexInfo = response.data;
            this.cdr.markForCheck();
        } catch (e) {
            this.common.generalHandleError(e);
        }
    }

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

    addField(): void {
        this.newIndexFields.push({ name: '', type: 'TEXT', sortable: false });
    }

    async confirmRemoveField(index: number): Promise<void> {
        try {
            const label = this.strings().intention?.delete;
            await this.common.confirm({ message: label + '?' });
            this.newIndexFields.splice(index, 1);
            this.newIndexFields = [...this.newIndexFields];
            this.cdr.markForCheck();
        } catch (e) {
            if (e !== undefined) this.common.generalHandleError(e);
        }
    }

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

    async handleSearchEnter(): Promise<void> {
        const q = (this.query || '').trim();

        // Explicit ai: prefix
        if (/^ai:\s*/i.test(q)) {
            await this.handleAiQuery(q.replace(/^ai:\s*/i, '').trim());
            return;
        }

        // Try normal search first
        try {
            await this.searchAndRefreshInfo();
        } catch (e: any) {
            // If search failed and query looks like natural language, try AI
            if (q.length > 2 && q !== '*' && /\s/.test(q)) {
                this.overlay.show();
                try {
                    await this.handleAiQuery(q);
                } finally {
                    this.overlay.hide();
                }
            }
        }
    }

    private async handleAiQuery(prompt: string): Promise<void> {
        if (!prompt) return;

        this.aiLoading = true;
        this.cdr.markForCheck();
        try {
            let indexSchema: any = undefined;
            if (this.selectedIndex && this.indexInfo) {
                indexSchema = this.indexInfo;
            }

            const response = await this.socket.request({
                action: 'ai/redis-query',
                payload: {
                    prompt,
                    context: {
                        indexes: this.indexes,
                        schema: indexSchema,
                    },
                },
            });

            this.query = response.command || '*';
            if (response.explanation) {
                this.common.toast({ message: response.explanation });
            }
            this.offset = 0;
            await this.searchAndRefreshInfo();
        } catch (e: any) {
            this.common.generalHandleError(e);
        } finally {
            this.aiLoading = false;
            this.cdr.markForCheck();
        }
    }

    getDocKeys(doc: any): string[] {
        return Object.keys(doc).filter(k => k !== '_key');
    }
}