feat(search): orchestrate NL search on the documents page (#739)
Lift smartMode to documents/+page.svelte and drive the full smart-search lifecycle: POST /api/search/nl via csrfFetch, loading/error panels, chip row, single-select disambiguation, and a transparent empty state. Chip removal and disambiguation selection map the interpretation to keyword params and re-run via GET (Option A in-page fallback). Mode toggle and new queries reset prior interpretation. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -8,9 +8,20 @@ import DocumentList from '../DocumentList.svelte';
|
||||
import Pagination from '$lib/shared/primitives/Pagination.svelte';
|
||||
import BulkSelectionBar from '$lib/document/BulkSelectionBar.svelte';
|
||||
import TimelineDensityFilter from '$lib/document/TimelineDensityFilter.svelte';
|
||||
import SmartSearchStatus from '../search/SmartSearchStatus.svelte';
|
||||
import InterpretationChipRow from '../search/InterpretationChipRow.svelte';
|
||||
import DisambiguationPicker from '../search/DisambiguationPicker.svelte';
|
||||
import { bulkSelectionStore } from '$lib/document/bulkSelection.svelte';
|
||||
import { getErrorMessage, parseBackendError } from '$lib/shared/errors';
|
||||
import { csrfFetch } from '$lib/shared/cookies';
|
||||
import * as m from '$lib/paraglide/messages.js';
|
||||
import type { components } from '$lib/generated/api';
|
||||
|
||||
type NlQueryInterpretation = components['schemas']['NlQueryInterpretation'];
|
||||
type NlSearchResponse = components['schemas']['NlSearchResponse'];
|
||||
type DocumentSearchResult = components['schemas']['DocumentSearchResult'];
|
||||
type PersonHint = components['schemas']['PersonHint'];
|
||||
type SmartSearchErrorCode = 'SMART_SEARCH_UNAVAILABLE' | 'SMART_SEARCH_RATE_LIMITED';
|
||||
|
||||
let { data } = $props();
|
||||
|
||||
@@ -34,6 +45,18 @@ let tagQ = $state(untrack(() => data.tagQ || ''));
|
||||
let tagOperator = $state<'AND' | 'OR'>(untrack(() => (data.tagOp as 'AND' | 'OR') || 'AND'));
|
||||
let undated = $state(untrack(() => data.undated ?? false));
|
||||
|
||||
// Smart (NL) search — UI-local state, resets on real page navigation (away + back).
|
||||
let smartMode = $state(false);
|
||||
let nlSubmitted = $state(false);
|
||||
let nlLoading = $state(false);
|
||||
let nlError = $state<SmartSearchErrorCode | null>(null);
|
||||
let nlInterpretation = $state<NlQueryInterpretation | null>(null);
|
||||
let nlResult = $state<DocumentSearchResult | null>(null);
|
||||
|
||||
const showNlView = $derived(smartMode && nlSubmitted);
|
||||
const nlHasResults = $derived((nlResult?.items.length ?? 0) > 0);
|
||||
const nlIsAmbiguous = $derived((nlInterpretation?.ambiguousPersons.length ?? 0) > 0);
|
||||
|
||||
function hasAdvancedFilters() {
|
||||
return (
|
||||
(data.tags?.length ?? 0) > 0 ||
|
||||
@@ -164,6 +187,121 @@ function handleImmediateSearch() {
|
||||
triggerSearchKeepZoom();
|
||||
}
|
||||
|
||||
function resetNlState() {
|
||||
nlSubmitted = false;
|
||||
nlLoading = false;
|
||||
nlError = null;
|
||||
nlInterpretation = null;
|
||||
nlResult = null;
|
||||
}
|
||||
|
||||
/** Toggling the mode (either direction) always clears any prior NL interpretation. */
|
||||
function onModeToggle() {
|
||||
resetNlState();
|
||||
}
|
||||
|
||||
/** Submit the natural-language query to the server-side parser. */
|
||||
async function runSmartSearch() {
|
||||
const query = q.trim();
|
||||
if (query.length < 3) return;
|
||||
nlSubmitted = true;
|
||||
nlLoading = true;
|
||||
nlError = null;
|
||||
nlInterpretation = null;
|
||||
nlResult = null;
|
||||
try {
|
||||
const res = await csrfFetch('/api/search/nl', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ query })
|
||||
});
|
||||
if (!res.ok) {
|
||||
const backend = await parseBackendError(res);
|
||||
nlError =
|
||||
backend?.code === 'SMART_SEARCH_RATE_LIMITED'
|
||||
? 'SMART_SEARCH_RATE_LIMITED'
|
||||
: 'SMART_SEARCH_UNAVAILABLE';
|
||||
return;
|
||||
}
|
||||
const body: NlSearchResponse = await res.json();
|
||||
nlInterpretation = body.interpretation;
|
||||
nlResult = body.result;
|
||||
} catch {
|
||||
nlError = 'SMART_SEARCH_UNAVAILABLE';
|
||||
} finally {
|
||||
nlLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Option A empty/error fallback: drop NL mode, keep the raw query, run a keyword search. */
|
||||
function switchToKeywordMode() {
|
||||
resetNlState();
|
||||
smartMode = false;
|
||||
handleImmediateSearch();
|
||||
}
|
||||
|
||||
/** Applies a resolved param set to the keyword filters and re-runs via GET. */
|
||||
function applyResolvedAndSearch(p: {
|
||||
senderId: string;
|
||||
receiverId: string;
|
||||
from: string;
|
||||
to: string;
|
||||
q: string;
|
||||
}) {
|
||||
resetNlState();
|
||||
smartMode = false;
|
||||
senderId = p.senderId;
|
||||
receiverId = p.receiverId;
|
||||
from = p.from;
|
||||
to = p.to;
|
||||
q = p.q;
|
||||
handleImmediateSearch();
|
||||
}
|
||||
|
||||
function paramsFromInterpretation(interp: NlQueryInterpretation) {
|
||||
const resolved = interp.resolvedPersons;
|
||||
return {
|
||||
senderId: resolved.length >= 1 ? resolved[0].id : '',
|
||||
receiverId: resolved.length >= 2 ? resolved[1].id : '',
|
||||
from: interp.dateFrom ?? '',
|
||||
to: interp.dateTo ?? '',
|
||||
q: interp.keywordsApplied ? interp.keywords.join(' ') : ''
|
||||
};
|
||||
}
|
||||
|
||||
type ChipType = 'sender' | 'directional' | 'date' | 'keyword';
|
||||
|
||||
function removeChip(type: ChipType, value?: string) {
|
||||
if (!nlInterpretation) return;
|
||||
const p = paramsFromInterpretation(nlInterpretation);
|
||||
if (type === 'sender') {
|
||||
p.senderId = '';
|
||||
} else if (type === 'directional') {
|
||||
p.senderId = '';
|
||||
p.receiverId = '';
|
||||
} else if (type === 'date') {
|
||||
p.from = '';
|
||||
p.to = '';
|
||||
} else if (type === 'keyword' && value) {
|
||||
const remaining = nlInterpretation.keywords.filter((keyword) => keyword !== value);
|
||||
p.q = remaining.join(' ');
|
||||
}
|
||||
applyResolvedAndSearch(p);
|
||||
}
|
||||
|
||||
/** Single-select disambiguation: resolved person becomes sender, chosen becomes receiver. */
|
||||
function selectDisambiguated(person: PersonHint) {
|
||||
if (!nlInterpretation) return;
|
||||
const resolved = nlInterpretation.resolvedPersons;
|
||||
applyResolvedAndSearch({
|
||||
senderId: resolved.length >= 1 ? resolved[0].id : person.id,
|
||||
receiverId: resolved.length >= 1 ? person.id : '',
|
||||
from: nlInterpretation.dateFrom ?? '',
|
||||
to: nlInterpretation.dateTo ?? '',
|
||||
q: nlInterpretation.keywordsApplied ? nlInterpretation.keywords.join(' ') : ''
|
||||
});
|
||||
}
|
||||
|
||||
// Trigger search reactively when the tag list changes.
|
||||
let prevTagStr = untrack(() => tagNames.map((t) => t.name).join(','));
|
||||
$effect(() => {
|
||||
@@ -268,6 +406,7 @@ $effect(() => {
|
||||
bind:tagQ={tagQ}
|
||||
bind:tagOperator={tagOperator}
|
||||
bind:undated={undated}
|
||||
bind:smartMode={smartMode}
|
||||
undatedCount={data.undatedCount ?? 0}
|
||||
initialSenderName={initialSenderName}
|
||||
initialReceiverName={initialReceiverName}
|
||||
@@ -275,10 +414,57 @@ $effect(() => {
|
||||
isLoading={navigating.to !== null}
|
||||
onSearch={handleTextSearch}
|
||||
onSearchImmediate={handleImmediateSearch}
|
||||
onSmartSearch={runSmartSearch}
|
||||
onModeToggle={onModeToggle}
|
||||
onfocus={() => (qFocused = true)}
|
||||
onblur={() => (qFocused = false)}
|
||||
/>
|
||||
|
||||
{#if showNlView}
|
||||
<!-- Smart-search results area: loading / error / chips + results / empty / disambiguation. -->
|
||||
{#if nlLoading}
|
||||
<SmartSearchStatus status="loading" />
|
||||
{:else if nlError}
|
||||
<SmartSearchStatus
|
||||
status="error"
|
||||
errorCode={nlError}
|
||||
onSwitchToKeyword={switchToKeywordMode}
|
||||
/>
|
||||
{:else if nlInterpretation}
|
||||
{#key nlInterpretation}
|
||||
<div class="mb-4">
|
||||
{#if nlIsAmbiguous}
|
||||
<DisambiguationPicker
|
||||
persons={nlInterpretation.ambiguousPersons}
|
||||
onSelect={selectDisambiguated}
|
||||
/>
|
||||
{:else}
|
||||
<InterpretationChipRow interpretation={nlInterpretation} onRemoveChip={removeChip} />
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if !nlIsAmbiguous}
|
||||
{#if nlHasResults}
|
||||
<p class="mb-3 font-sans text-base text-ink-2">
|
||||
{m.docs_result_count({ count: nlResult?.totalElements ?? 0 })}
|
||||
</p>
|
||||
<DocumentList items={nlResult?.items ?? []} canWrite={data.canWrite} sort={sort} />
|
||||
{:else}
|
||||
<div class="flex flex-col items-center justify-center gap-3 py-16 text-center">
|
||||
<p class="text-sm font-bold text-ink">{m.search_empty_nl()}</p>
|
||||
<button
|
||||
type="button"
|
||||
onclick={switchToKeywordMode}
|
||||
class="inline-flex min-h-[44px] items-center rounded px-3 py-2 text-sm font-bold text-primary underline underline-offset-4 outline-none hover:text-brand-mint focus-visible:ring-2 focus-visible:ring-brand-navy"
|
||||
>
|
||||
{m.search_empty_retry_keyword()}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
{/key}
|
||||
{/if}
|
||||
{:else}
|
||||
<div class="mt-3 mb-4 hidden lg:block">
|
||||
<TimelineDensityFilter
|
||||
density={data.density}
|
||||
@@ -362,6 +548,7 @@ $effect(() => {
|
||||
/>
|
||||
|
||||
<Pagination page={data.pageNumber} totalPages={data.totalPages} makeHref={buildPageHref} />
|
||||
{/if}
|
||||
</main>
|
||||
|
||||
<BulkSelectionBar canWrite={data.canWrite} />
|
||||
|
||||
Reference in New Issue
Block a user