import { ArrowLeft, ChevronDown, FileText, HomeIcon, Search as SearchIcon, SlidersHorizontal, XIcon, } from "lucide-react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { resolveDepthPolicy } from "../../../core/depth-policy"; import { normalizeStructuredQueryInput } from "../../../core/structured-query"; import { Loader } from "../components/ai-elements/loader"; import { MetadataFilter } from "../components/MetadataFilter"; import { ThoroughnessSelector, type Thoroughness, } from "../components/ThoroughnessSelector"; import { Badge } from "../components/ui/badge"; import { Button } from "../components/ui/button"; import { Card, CardContent } from "../components/ui/card"; import { Collapsible, CollapsibleContent, CollapsibleTrigger, } from "../components/ui/collapsible"; import { Input } from "../components/ui/input"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "../components/ui/select"; import { Textarea } from "../components/ui/textarea"; import { apiFetch } from "../hooks/use-api"; import { useDocEvents } from "../hooks/use-doc-events"; import { useKeyboardShortcuts } from "../hooks/useKeyboardShortcuts"; import { buildDocDeepLink } from "../lib/deep-links"; import { parseMetadataFilter } from "../lib/metadata-filter"; import { applyFiltersToUrl, parseFiltersFromSearch, type QueryModeEntry, type QueryModeType, type TagMode, } from "../lib/retrieval-filters"; import { fetchServerCapabilities, type ServerCapabilities, } from "../lib/server-capabilities"; import { cn } from "../lib/utils"; import { AIModelSelector, TagFacets } from "./search-page-widgets"; /** * Render snippet with tags as highlighted spans. * Only allows mark tags - strips all other HTML for safety. */ function renderSnippet(snippet: string): React.ReactNode { const parts: React.ReactNode[] = []; let remaining = snippet; let key = 0; while (remaining.length > 0) { const markStart = remaining.indexOf(""); if (markStart === -1) { parts.push(remaining); break; } if (markStart > 0) { parts.push(remaining.slice(0, markStart)); } const markEnd = remaining.indexOf("", markStart); if (markEnd === -1) { parts.push(remaining.slice(markStart)); break; } const highlighted = remaining.slice(markStart + 6, markEnd); parts.push( {highlighted} ); remaining = remaining.slice(markEnd + 7); } return parts; } interface PageProps { navigate: (to: string | number) => void; } interface SearchResult { docid: string; uri: string; title?: string; snippet: string; score: number; snippetRange?: { startLine: number; endLine: number; }; } interface SearchResponse { results: SearchResult[]; meta: { warnings?: { code: string; message: string }[]; query: string; mode: string; totalResults: number; expanded?: boolean; reranked?: boolean; vectorsUsed?: boolean; queryModes?: { term: number; intent: number; hyde: boolean; }; }; } interface Collection { name: string; } interface PresetsResponse { activePreset: string; } const THOROUGHNESS_ORDER: Thoroughness[] = ["fast", "balanced", "thorough"]; const QUERY_MODE_LABEL: Record = { term: "Term", intent: "Intent", hyde: "HyDE", }; export default function Search({ navigate }: PageProps) { const initialFilters = useMemo( () => parseFiltersFromSearch(window.location.search), [] ); const [query, setQuery] = useState(""); const [thoroughness, setThoroughness] = useState("balanced"); const [results, setResults] = useState([]); const [meta, setMeta] = useState(null); const [loading, setLoading] = useState(false); const searchRequest = useRef(0); const [error, setError] = useState(null); const [searched, setSearched] = useState(false); const [capabilities, setCapabilities] = useState( null ); const [collections, setCollections] = useState([]); const [activePreset, setActivePreset] = useState("slim-tuned"); const [showAdvanced, setShowAdvanced] = useState( Boolean( initialFilters.collection || initialFilters.intent || initialFilters.candidateLimit || initialFilters.exclude || initialFilters.since || initialFilters.until || initialFilters.category || initialFilters.author || initialFilters.filter || initialFilters.queryModes.length > 0 ) ); const [activeTags, setActiveTags] = useState(initialFilters.tags); const [tagMode, setTagMode] = useState(initialFilters.tagMode); const [selectedCollection, setSelectedCollection] = useState( initialFilters.collection ); const [intent, setIntent] = useState(initialFilters.intent); const [candidateLimit, setCandidateLimit] = useState( initialFilters.candidateLimit ); const [exclude, setExclude] = useState(initialFilters.exclude); const [since, setSince] = useState(initialFilters.since); const [until, setUntil] = useState(initialFilters.until); const [category, setCategory] = useState(initialFilters.category); const [metadataFilter, setMetadataFilter] = useState( initialFilters.filter ?? "" ); const [author, setAuthor] = useState(initialFilters.author); const [queryModes, setQueryModes] = useState( initialFilters.queryModes ); const [queryModeDraft, setQueryModeDraft] = useState("term"); const [queryModeText, setQueryModeText] = useState(""); const [queryModeError, setQueryModeError] = useState(null); const [showMobileTags, setShowMobileTags] = useState(false); const latestDocEvent = useDocEvents(); const hybridAvailable = capabilities?.hybrid ?? false; const structuredQueryState = useMemo( () => normalizeStructuredQueryInput(query, queryModes), [query, queryModes] ); const structuredQueryError = query.trim().length > 0 && !structuredQueryState.ok ? structuredQueryState.error.message : null; const forceHybridForModes = thoroughness === "fast" && (queryModes.length > 0 || intent.trim().length > 0 || (structuredQueryState.ok && structuredQueryState.value.usedStructuredQuerySyntax)); // Sync URL as filter state changes. useEffect(() => { const url = new URL(window.location.href); applyFiltersToUrl(url, { filter: metadataFilter, collection: selectedCollection, intent, candidateLimit, exclude, since, until, category, author, tagMode, tags: activeTags, queryModes, }); window.history.replaceState({}, "", url.toString()); }, [ activeTags, metadataFilter, author, candidateLimit, category, exclude, intent, queryModes, selectedCollection, since, tagMode, until, ]); const handleTagSelect = useCallback((tag: string) => { setActiveTags((prev) => (prev.includes(tag) ? prev : [...prev, tag])); }, []); const handleTagRemove = useCallback((tag: string) => { setActiveTags((prev) => prev.filter((t) => t !== tag)); }, []); useEffect(() => { async function bootstrap(): Promise { const [capabilitiesResult, collectionsResult, presetsResult] = await Promise.all([ fetchServerCapabilities(), apiFetch("/api/collections"), apiFetch("/api/presets"), ]); if (capabilitiesResult.data) { const caps = capabilitiesResult.data; setCapabilities(caps); setThoroughness(caps.hybrid ? "balanced" : "fast"); } if (collectionsResult.data) { setCollections(collectionsResult.data); } if (presetsResult.data?.activePreset) { setActivePreset(presetsResult.data.activePreset); } } void bootstrap(); }, []); const cycleThoroughness = useCallback(() => { setThoroughness((current) => { if (!hybridAvailable) return "fast"; const currentIdx = THOROUGHNESS_ORDER.indexOf(current); const nextIdx = (currentIdx + 1) % THOROUGHNESS_ORDER.length; return THOROUGHNESS_ORDER[nextIdx] ?? "fast"; }); }, [hybridAvailable]); const shortcuts = useMemo( () => [{ key: "t", action: cycleThoroughness }], [cycleThoroughness] ); useKeyboardShortcuts(shortcuts); const handleAddQueryMode = useCallback(() => { const text = queryModeText.trim(); if (!text) { return; } if ( queryModeDraft === "hyde" && queryModes.some((queryMode) => queryMode.mode === "hyde") ) { setQueryModeError("Only one HyDE mode is allowed."); return; } setQueryModes((prev) => [...prev, { mode: queryModeDraft, text }]); setQueryModeText(""); setQueryModeError(null); }, [queryModeDraft, queryModeText, queryModes]); const handleRemoveQueryMode = useCallback((index: number) => { setQueryModes((prev) => prev.filter((_, i) => i !== index)); setQueryModeError(null); }, []); const handleSearch = useCallback( async (e?: React.FormEvent) => { e?.preventDefault(); const request = ++searchRequest.current; if (!query.trim()) { return; } const parsedFilter = parseMetadataFilter(metadataFilter); if (parsedFilter.error) { setLoading(false); setResults([]); setMeta(null); setError(parsedFilter.error); setShowAdvanced(true); return; } if (!structuredQueryState.ok) { setLoading(false); setResults([]); setMeta(null); setError(structuredQueryState.error.message); return; } setLoading(true); setError(null); setSearched(true); const depthPolicy = resolveDepthPolicy({ presetId: activePreset, fast: thoroughness === "fast", thorough: thoroughness === "thorough", hasStructuredModes: queryModes.length > 0, candidateLimit: candidateLimit.trim() ? Number(candidateLimit) : undefined, }); const useBm25 = thoroughness === "fast" && queryModes.length === 0 && intent.trim().length === 0 && !structuredQueryState.value.usedStructuredQuerySyntax; const endpoint = useBm25 ? "/api/search" : "/api/query"; const body: Record = { query, limit: 20, }; if (parsedFilter.filter) body.filter = parsedFilter.filter; if (selectedCollection) { body.collection = selectedCollection; } if (intent.trim()) { body.intent = intent.trim(); } if (depthPolicy.candidateLimit !== undefined) { body.candidateLimit = depthPolicy.candidateLimit; } if (exclude.trim()) { body.exclude = exclude.trim(); } if (since) { body.since = since; } if (until) { body.until = until; } if (category.trim()) { body.category = category.trim(); } if (author.trim()) { body.author = author.trim(); } if (activeTags.length > 0) { if (tagMode === "all") { body.tagsAll = activeTags.join(","); } else { body.tagsAny = activeTags.join(","); } } if (!useBm25) { body.noExpand = depthPolicy.noExpand; body.noRerank = depthPolicy.noRerank; body.noGraph = thoroughness === "fast"; if (queryModes.length > 0) { body.queryModes = queryModes; } } const { data, error: fetchError } = await apiFetch( endpoint, { method: "POST", body: JSON.stringify(body), } ); if (request !== searchRequest.current) return; setLoading(false); if (fetchError) { setError(fetchError); setResults([]); setMeta(null); } else if (data) { setResults(data.results); setMeta(data.meta); } }, [ activeTags, metadataFilter, author, candidateLimit, activePreset, category, exclude, intent, query, queryModes, selectedCollection, since, structuredQueryState, tagMode, thoroughness, until, ] ); // Re-search when filters change after an initial search. useEffect(() => { if (searched && query.trim()) { void handleSearch(); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [ activeTags, activePreset, metadataFilter, author, candidateLimit, category, exclude, intent, queryModes, selectedCollection, since, tagMode, until, ]); useEffect(() => { if (!latestDocEvent?.changedAt) { return; } if (searched && query.trim()) { void handleSearch(); } }, [handleSearch, latestDocEvent?.changedAt, query, searched]); const thoroughnessDesc = thoroughness === "fast" ? "Keyword search (BM25)" : thoroughness === "balanced" ? resolveDepthPolicy({ presetId: activePreset }) .balancedExpansionEnabled ? "Hybrid + tuned expansion" : "Hybrid + reranking" : "Expansion + reranking + wider candidate pool"; const activeFilterPills = [ selectedCollection ? `collection:${selectedCollection}` : null, intent.trim() ? `intent:${intent.trim()}` : null, candidateLimit.trim() ? `candidates:${candidateLimit.trim()}` : null, exclude.trim() ? `exclude:${exclude.trim()}` : null, since ? `since:${since}` : null, until ? `until:${until}` : null, category.trim() ? `category:${category.trim()}` : null, metadataFilter.trim() ? "custom metadata" : null, author.trim() ? `author:${author.trim()}` : null, queryModes.length > 0 ? `${queryModes.length} query mode(s)` : null, ].filter((pill): pill is string => Boolean(pill)); const hasActiveFilters = activeFilterPills.length > 0 || activeTags.length > 0; const clearAdvancedFilters = () => { setSelectedCollection(""); setIntent(""); setCandidateLimit(""); setExclude(""); setSince(""); setUntil(""); setCategory(""); setAuthor(""); setMetadataFilter(""); setQueryModes([]); setQueryModeText(""); setQueryModeError(null); }; const handleQueryKeyDown = (e: React.KeyboardEvent) => { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); void handleSearch(); } }; const browseSelectedCollection = () => { if (!selectedCollection) { navigate("/browse"); return; } navigate(`/browse?collection=${encodeURIComponent(selectedCollection)}`); }; return (

Search