feat(search): suggestions sans doublons + suggestions locales a la frappe

This commit is contained in:
2026-09-26 13:09:37 -04:00
parent 55c02ac52f
commit 4578f5b887
7 changed files with 206 additions and 25 deletions
+34 -12
View File
@@ -49,36 +49,48 @@ export class HeaderComponent {
// Suggestion item shape: history vs generated
readonly suggestionItems = computed<{ text: string; source: 'history' | 'generated' }[]>(() => {
type Item = { text: string; source: 'history' | 'generated' };
const norm = (s: string) => String(s || '').trim().toLowerCase().replace(/\s+/g, ' ');
const q = (this.searchQuery || '').trim();
const provider = this.selectedProvider();
const fromHistory = (this.recentSearches() || [])
.map(it => (it.query || '').trim())
.filter(Boolean);
// Historique dédupliqué (la même recherche ne s'affiche qu'une fois).
const seenHistory = new Set<string>();
const historyBase: string[] = [];
for (const it of this.recentSearches() || []) {
const txt = (it.query || '').trim();
if (!txt) continue;
const k = norm(txt);
if (seenHistory.has(k)) continue;
seenHistory.add(k);
historyBase.push(txt);
}
// 1) Base list from history
let historyList: string[];
if (!q) {
historyList = fromHistory.slice(0, 15);
historyList = historyBase.slice(0, 15);
} else {
const lower = q.toLowerCase();
historyList = fromHistory.filter(txt => txt.toLowerCase().includes(lower)).slice(0, 15);
historyList = historyBase.filter(txt => txt.toLowerCase().includes(lower)).slice(0, 15);
}
const items: Item[] = historyList.map(text => ({ text, source: 'history' }));
// 2) If we have fewer than 15, fill with generated suggestions
if (items.length < 15) {
const need = 15 - items.length;
const generated = this.generateQuerySuggestions(q, provider);
// Avoid duplicates with history
const existing = new Set(items.map(i => i.text.toLowerCase()));
// Avoid duplicates with history (normalized comparison)
const existing = new Set(items.map(i => norm(i.text)));
for (const g of generated) {
const t = (g || '').trim();
if (!t) continue;
if (existing.has(t.toLowerCase())) continue;
const k = norm(t);
if (existing.has(k)) continue;
existing.add(k);
items.push({ text: t, source: 'generated' });
if (items.length >= 15) break;
}
void need;
}
// Reset highlight if list size changes or becomes empty
@@ -152,10 +164,12 @@ export class HeaderComponent {
tokens.push(`${base} highlights`, `${base} podcast`);
}
// Keep unique and meaningful
// Keep unique and meaningful (normalized: case + whitespace)
const normTok = (s: string) => String(s || '').trim().toLowerCase().replace(/\s+/g, ' ');
const seen = new Set<string>();
for (const t of tokens) {
const key = t.toLowerCase();
const key = normTok(t);
if (!key || key === normTok(base)) continue;
if (!seen.has(key)) {
seen.add(key);
suggestions.push(t);
@@ -352,7 +366,15 @@ export class HeaderComponent {
this.history.getSearchHistory(15).subscribe({
next: (items) => {
// API likely returns newest first; ensure we keep order and dedupe by query
this.recentSearches.set(items || []);
const seen = new Set<string>();
const clean: SearchHistoryItem[] = [];
for (const it of items || []) {
const k = String(it?.query || '').trim().toLowerCase().replace(/\s+/g, ' ');
if (!k || seen.has(k)) continue;
seen.add(k);
clean.push(it);
}
this.recentSearches.set(clean);
},
error: () => {}
});
+51 -11
View File
@@ -11,7 +11,7 @@ import { Subject, of } from 'rxjs';
import { debounceTime, distinctUntilChanged, switchMap, catchError, finalize } from 'rxjs/operators';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { SuggestService } from '../../app/search/suggest.service';
import { mergeGroups, highlightParts } from '../../app/search/suggest.util';
import { mergeGroups, highlightParts, dedupeStrings, buildLocalQuerySuggestions, normSuggest } from '../../app/search/suggest.util';
// No overlays; we render fixed-position modals with high z-index
const AT_QUERY_RE = /(^|\s)@([a-z]{1,24})$/i;
@@ -87,16 +87,32 @@ export class SearchBoxComponent {
private suggestShownFor: string | null = null;
readonly suggestFlat = computed((): SuggestFlatItem[] => {
const q = this.query();
// Tier 1 — recherches récentes (dédupliquées, une seule fois chacune).
const recents = (this.recentSuggest() || []).slice(0, 5).map((t) => ({ text: t, provider: null as string | null }));
const seen = new Set(recents.map((r) => r.text.toLowerCase()));
const seen = new Set(recents.map((r) => normSuggest(r.text)));
// Tier 2 — suggestions providers, dédupliquées entre providers (mergeGroups)
// et contre l'historique.
const merged = mergeGroups(this.suggestGroups() || {}).filter((m) => {
const k = m.text.toLowerCase();
const k = normSuggest(m.text);
if (seen.has(k)) return false;
seen.add(k);
return true;
});
const out: SuggestFlatItem[] = [...recents, ...merged];
// Tier 3 — suggestions locales générées à la frappe (sans réseau) : la
// frappe propose toujours quelque chose, même sans provider suggest
// (tw/pt/od/ru), hors-ligne ou en cas de réponse vide.
if (out.length < 10) {
for (const t of buildLocalQuerySuggestions(q, 10 - out.length)) {
const k = normSuggest(t);
if (seen.has(k)) continue;
seen.add(k);
out.push({ text: t, provider: null });
if (out.length >= 15) break;
}
}
void q;
return [...recents, ...merged].slice(0, 15);
return out.slice(0, 15);
});
// Element to restore focus to when a modal closes (a11y)
@@ -247,21 +263,28 @@ export class SearchBoxComponent {
/** Filter cached history synchronously; refresh the cache at most every 10s. */
private refreshRecentSuggest(value: string) {
const q = String(value || '').trim().toLowerCase();
if (q.length >= 2 && this.allRecentCache.length > 0) {
this.recentSuggest.set(this.allRecentCache.filter((t) => t.toLowerCase().includes(q)).slice(0, 5));
if (this.allRecentCache.length > 0) {
// Historique dédupliqué : la même recherche ne s'affiche qu'une fois.
this.recentSuggest.set(
q.length >= 2
? this.allRecentCache.filter((t) => t.toLowerCase().includes(q)).slice(0, 5)
: this.allRecentCache.slice(0, 5),
);
} else if (q.length < 2) {
this.recentSuggest.set([]);
}
const now = Date.now();
if (q.length < 2 || now - this.lastRecentFetch < 10_000) return;
if (now - this.lastRecentFetch < 10_000) return;
this.lastRecentFetch = now;
try {
this.history.getSearchHistory(15).subscribe({
next: (items) => {
this.allRecentCache = (items || []).map((i) => String(i?.query || '').trim()).filter(Boolean);
this.allRecentCache = dedupeStrings((items || []).map((i) => String(i?.query || '').trim()).filter(Boolean));
const cur = String(this.query() || '').trim().toLowerCase();
if (cur.length >= 2) {
this.recentSuggest.set(this.allRecentCache.filter((t) => t.toLowerCase().includes(cur)).slice(0, 5));
} else {
this.recentSuggest.set(this.allRecentCache.slice(0, 5));
}
},
error: () => {},
@@ -300,7 +323,9 @@ export class SearchBoxComponent {
}
onSuggestInputFocus() {
if (!this.atOpen() && String(this.query() || '').trim().length >= 2 && this.suggestFlat().length > 0) {
// Même avec moins de 2 caractères, on propose l'historique récent.
this.refreshRecentSuggest(this.query());
if (!this.atOpen() && this.suggestFlat().length > 0) {
this.suggestionsOpen.set(true);
}
}
@@ -475,8 +500,23 @@ export class SearchBoxComponent {
this.saveFocus();
this.quickOpen.set(true);
this.telemetry.track('quick_menu_open');
// Load last 15 searches
try { this.history.getSearchHistory(15).subscribe({ next: (items) => this.recentSearches.set(items || []), error: () => {} }); } catch {}
// Load last 15 searches (deduped: same query shown once)
try {
this.history.getSearchHistory(15).subscribe({
next: (items) => {
const seen = new Set<string>();
const clean: SearchHistoryItem[] = [];
for (const it of items || []) {
const k = normSuggest(it?.query || '');
if (!k || seen.has(k)) continue;
seen.add(k);
clean.push(it);
}
this.recentSearches.set(clean);
},
error: () => {},
});
} catch {}
}
closeQuickMenu() {
if (!this.quickOpen()) return;