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 { 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'; require('./search.component.scss'); @Component({ selector: 'p3xr-search', standalone: true, imports: [ CommonModule, FormsModule, MatIconModule, MatButtonModule, MatTooltipModule, MatDividerModule, MatListModule, MatSelectModule, MatFormFieldModule, MatInputModule, P3xrAccordionComponent, P3xrButtonComponent, ], templateUrl: './search.component.html', 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; // 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 { 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 { 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 { if (!this.selectedIndex || !this.query) return; this.searching = true; this.cdr.markForCheck(); try { const 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 { 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 { if (!this.selectedIndex) return; try { await this.common.confirm({ message: this.strings().confirm?.dropIndex || 'Are you sure to drop this index?', }); await this.socket.request({ action: 'search-index-drop', payload: { index: this.selectedIndex }, }); this.common.toast({ message: this.strings().status?.indexDropped || 'Index dropped' }); 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 { try { const label = this.strings().intention?.delete || '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 { 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 || 'Index created' }); this.newIndexName = ''; this.newIndexPrefix = ''; this.newIndexFields = [{ name: '', type: 'TEXT', sortable: false }]; await this.loadIndexes(); } catch (e) { this.common.generalHandleError(e); } } async handleSearchEnter(): Promise { 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 { 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'); } }