feat: add Meilisearch backend integration with Docker Compose setup and Excalidraw support
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
export const environment = {
|
||||
production: false,
|
||||
appVersion: '0.1.0',
|
||||
USE_MEILI: true, // Set to true to enable Meilisearch backend search
|
||||
logging: {
|
||||
enabled: true,
|
||||
endpoint: '/api/log',
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { isObservable } from 'rxjs';
|
||||
import { SearchOrchestratorService } from './search-orchestrator.service';
|
||||
import { SearchOptions } from './search-parser.types';
|
||||
|
||||
@@ -45,8 +46,12 @@ export class SearchEvaluatorService {
|
||||
*/
|
||||
search(query: string, options?: SearchOptions): SearchResult[] {
|
||||
// Delegate to the new orchestrator
|
||||
const results = this.orchestrator.execute(query, options);
|
||||
|
||||
const exec = this.orchestrator.execute(query, options);
|
||||
if (isObservable(exec)) {
|
||||
// Legacy API is synchronous; when Meili (Observable) is enabled, return empty for compatibility.
|
||||
return [];
|
||||
}
|
||||
const results = exec;
|
||||
// Convert to legacy format (without ranges)
|
||||
return results.map(result => ({
|
||||
noteId: result.noteId,
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
import { inject, Injectable, signal } from '@angular/core';
|
||||
import { HttpClient, HttpParams } from '@angular/common/http';
|
||||
import { Observable } from 'rxjs';
|
||||
import { finalize } from 'rxjs/operators';
|
||||
|
||||
/**
|
||||
* Search hit from Meilisearch with highlighting
|
||||
*/
|
||||
export interface SearchHit {
|
||||
id: string;
|
||||
title: string;
|
||||
path: string;
|
||||
file: string;
|
||||
tags?: string[];
|
||||
properties?: Record<string, unknown>;
|
||||
updatedAt?: number;
|
||||
createdAt?: number;
|
||||
excerpt?: string;
|
||||
_formatted?: {
|
||||
title?: string;
|
||||
content?: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Search response from Meilisearch API
|
||||
*/
|
||||
export interface SearchResponse {
|
||||
hits: SearchHit[];
|
||||
estimatedTotalHits: number;
|
||||
facetDistribution?: Record<string, Record<string, number>>;
|
||||
processingTimeMs: number;
|
||||
query: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Search options
|
||||
*/
|
||||
export interface SearchOptions {
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
sort?: string;
|
||||
highlight?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Service for server-side search using Meilisearch
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class SearchMeilisearchService {
|
||||
private http = inject(HttpClient);
|
||||
|
||||
/** Loading state signal */
|
||||
loading = signal(false);
|
||||
|
||||
/**
|
||||
* Execute a search query
|
||||
*/
|
||||
search(q: string, opts?: SearchOptions): Observable<SearchResponse> {
|
||||
this.loading.set(true);
|
||||
|
||||
let params = new HttpParams().set('q', q);
|
||||
|
||||
if (opts?.limit !== undefined) {
|
||||
params = params.set('limit', String(opts.limit));
|
||||
}
|
||||
if (opts?.offset !== undefined) {
|
||||
params = params.set('offset', String(opts.offset));
|
||||
}
|
||||
if (opts?.sort) {
|
||||
params = params.set('sort', opts.sort);
|
||||
}
|
||||
if (opts?.highlight === false) {
|
||||
params = params.set('highlight', 'false');
|
||||
}
|
||||
|
||||
const url = `/api/search?${params.toString()}`;
|
||||
console.log('[SearchMeilisearchService] Sending request:', url);
|
||||
|
||||
return this.http
|
||||
.get<SearchResponse>('/api/search', { params })
|
||||
.pipe(
|
||||
finalize(() => {
|
||||
console.log('[SearchMeilisearchService] Request completed');
|
||||
this.loading.set(false);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Trigger a full reindex of the vault
|
||||
*/
|
||||
reindex(): Observable<{ ok: boolean; indexed: boolean; count: number; elapsedMs: number }> {
|
||||
this.loading.set(true);
|
||||
|
||||
return this.http
|
||||
.post<{ ok: boolean; indexed: boolean; count: number; elapsedMs: number }>(
|
||||
'/api/reindex',
|
||||
{}
|
||||
)
|
||||
.pipe(finalize(() => this.loading.set(false)));
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,10 @@ import { ClientLoggingService } from '../../services/client-logging.service';
|
||||
import { SearchLogService } from '../../app/core/logging/log.service';
|
||||
import { SearchDiagEvent } from '../../app/core/logging/search-log.model';
|
||||
import { ParseDiagnostics } from './search-parser.types';
|
||||
import { SearchMeilisearchService } from './search-meilisearch.service';
|
||||
import { environment } from '../logging/environment';
|
||||
import { Observable, of } from 'rxjs';
|
||||
import { map } from 'rxjs/operators';
|
||||
|
||||
/**
|
||||
* Match range for highlighting
|
||||
@@ -69,11 +73,18 @@ export class SearchOrchestratorService {
|
||||
private searchIndex = inject(SearchIndexService);
|
||||
private logger = inject(ClientLoggingService);
|
||||
private diagnosticsLogger = inject(SearchLogService);
|
||||
private meiliSearch = inject(SearchMeilisearchService);
|
||||
|
||||
/**
|
||||
* Execute a search query and return matching notes with highlights
|
||||
* Delegates to Meilisearch if USE_MEILI is enabled
|
||||
*/
|
||||
execute(query: string, options?: SearchExecutionOptions): SearchResult[] {
|
||||
execute(query: string, options?: SearchExecutionOptions): SearchResult[] | Observable<SearchResult[]> {
|
||||
// Use Meilisearch backend if enabled
|
||||
if (environment.USE_MEILI) {
|
||||
return this.searchWithMeili(query, options);
|
||||
}
|
||||
|
||||
if (!query || !query.trim()) {
|
||||
return [];
|
||||
}
|
||||
@@ -803,4 +814,75 @@ export class SearchOrchestratorService {
|
||||
|
||||
return score;
|
||||
}
|
||||
|
||||
/**
|
||||
* Search using Meilisearch backend
|
||||
*/
|
||||
private searchWithMeili(query: string, options?: SearchExecutionOptions): Observable<SearchResult[]> {
|
||||
this.logger.info('SearchOrchestrator', 'Using Meilisearch backend', { query });
|
||||
console.log('[SearchOrchestrator] Calling Meilisearch with query:', query);
|
||||
|
||||
return this.meiliSearch.search(query, {
|
||||
limit: options?.maxResults ?? 20,
|
||||
highlight: options?.highlight !== false
|
||||
}).pipe(
|
||||
map(response => {
|
||||
this.logger.info('SearchOrchestrator', 'Meilisearch response', {
|
||||
hitCount: response.hits.length,
|
||||
processingTimeMs: response.processingTimeMs
|
||||
});
|
||||
console.log('[SearchOrchestrator] Raw Meilisearch response:', response);
|
||||
|
||||
// Transform Meilisearch hits to SearchResult format
|
||||
return response.hits.map(hit => {
|
||||
const matches: SearchMatch[] = [];
|
||||
|
||||
// Prefer Meilisearch-provided highlights if available
|
||||
if (hit._formatted?.title && hit._formatted.title !== hit.title) {
|
||||
matches.push({
|
||||
type: 'content',
|
||||
text: hit.title,
|
||||
context: hit._formatted.title,
|
||||
ranges: []
|
||||
});
|
||||
}
|
||||
if (hit._formatted?.content) {
|
||||
matches.push({
|
||||
type: 'content',
|
||||
text: hit.excerpt || '',
|
||||
context: hit._formatted.content,
|
||||
ranges: []
|
||||
});
|
||||
}
|
||||
|
||||
// Fallback when no highlights were returned by Meilisearch
|
||||
if (matches.length === 0) {
|
||||
// Use excerpt if present, otherwise a cropped portion of content
|
||||
const context = hit.excerpt || (typeof (hit as any).content === 'string' ? String((hit as any).content).slice(0, 200) + '…' : '');
|
||||
if (context) {
|
||||
matches.push({
|
||||
type: 'content',
|
||||
text: '',
|
||||
context,
|
||||
ranges: []
|
||||
});
|
||||
}
|
||||
this.logger.debug('SearchOrchestrator', 'No highlight in Meilisearch hit, used fallback context', {
|
||||
id: hit.id,
|
||||
title: hit.title
|
||||
});
|
||||
}
|
||||
|
||||
const result = {
|
||||
noteId: hit.id,
|
||||
matches,
|
||||
score: 100, // Meilisearch has its own ranking
|
||||
allRanges: []
|
||||
};
|
||||
console.log('[SearchOrchestrator] Transformed hit:', { id: hit.id, matchCount: matches.length, result });
|
||||
return result;
|
||||
});
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -119,4 +119,6 @@ export interface SearchOptions {
|
||||
caseSensitive?: boolean;
|
||||
/** Enable regex mode */
|
||||
regexMode?: boolean;
|
||||
/** Enable UI highlighting in results and active document */
|
||||
highlight?: boolean;
|
||||
}
|
||||
|
||||
@@ -16,6 +16,8 @@ export interface SearchPreferences {
|
||||
contextLines: number;
|
||||
/** Explain search terms */
|
||||
explainSearchTerms: boolean;
|
||||
/** Enable UI highlighting in results and active document */
|
||||
highlight: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -27,7 +29,8 @@ const DEFAULT_PREFERENCES: SearchPreferences = {
|
||||
collapseResults: false,
|
||||
showMoreContext: false,
|
||||
contextLines: 2,
|
||||
explainSearchTerms: false
|
||||
explainSearchTerms: false,
|
||||
highlight: true
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -84,7 +87,7 @@ export class SearchPreferencesService {
|
||||
*/
|
||||
togglePreference(
|
||||
context: string,
|
||||
key: keyof Pick<SearchPreferences, 'caseSensitive' | 'regexMode' | 'collapseResults' | 'showMoreContext' | 'explainSearchTerms'>
|
||||
key: keyof Pick<SearchPreferences, 'caseSensitive' | 'regexMode' | 'collapseResults' | 'showMoreContext' | 'explainSearchTerms' | 'highlight'>
|
||||
): void {
|
||||
const current = this.getPreferences(context);
|
||||
this.updatePreferences(context, {
|
||||
|
||||
Reference in New Issue
Block a user