feat: add Meilisearch backend integration with Docker Compose setup and Excalidraw support

This commit is contained in:
2025-10-14 10:41:15 -04:00
parent b359e8ab8e
commit 32a9998b40
71 changed files with 11544 additions and 435 deletions
+1
View File
@@ -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',
+7 -2
View File
@@ -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)));
}
}
+83 -1
View File
@@ -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;
});
})
);
}
}
+2
View File
@@ -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, {