import { ArrowLeft, BookOpen, ChevronDown, CornerDownLeft, FileText, HomeIcon, SlidersHorizontal, Sparkles, 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 { Source, Sources, SourcesContent, SourcesTrigger, } from "../components/ai-elements/sources"; import { AIModelSelector } from "../components/AIModelSelector"; import { type AskVerification, AskVerificationPanel, } from "../components/AskVerificationPanel"; 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 { useKeyboardShortcuts } from "../hooks/useKeyboardShortcuts"; import { parseMetadataFilter } from "../lib/metadata-filter"; import { parseTagsCsv, type QueryModeEntry, type QueryModeType, type TagMode, } from "../lib/retrieval-filters"; import { fetchServerCapabilities, type ServerCapabilities, } from "../lib/server-capabilities"; import { cn } from "../lib/utils"; interface PageProps { navigate: (to: string | number) => void; } interface Citation { evidenceId?: string; docid: string; uri: string; startLine?: number; endLine?: number; } interface SearchResult { docid: string; uri: string; title?: string; snippet: string; score: number; snippetRange?: { startLine: number; endLine: number; }; } interface AskResponse { query: string; mode: string; queryLanguage: string; answer?: string; citations?: Citation[]; results: SearchResult[]; meta: { warnings?: { code: string; message: string }[]; expanded: boolean; reranked: boolean; vectorsUsed: boolean; answerGenerated: boolean; totalResults: number; verificationRequested?: boolean; abstained?: boolean; queryModes?: { term: number; intent: number; hyde: boolean; }; }; verification?: AskVerification; } interface ConversationEntry { id: string; query: string; response: AskResponse | null; loading: boolean; error?: string; } 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", }; /** * Render answer text with clickable citation badges. * Citations like [1] become clickable to navigate to source. */ function renderAnswer( answer: string, citations: Citation[], navigate: (to: string) => void ): React.ReactNode { const parts: React.ReactNode[] = []; let key = 0; const citationRegex = /\[(?:evidence:([a-f0-9]{64})|(\d+))\]/g; let lastIndex = 0; let match: RegExpExecArray | null; // oxlint-disable-next-line no-cond-assign -- Standard regex match pattern while ((match = citationRegex.exec(answer)) !== null) { if (match.index > lastIndex) { parts.push(answer.slice(lastIndex, match.index)); } const evidenceId = match[1]; const numericCitation = match[2] ? Number(match[2]) : undefined; const citationIndex = evidenceId ? citations.findIndex((citation) => citation.evidenceId === evidenceId) : (numericCitation ?? 0) - 1; const citationNum = citationIndex + 1; const citation = citations[citationIndex]; if (citation) { parts.push( ); } else { parts.push(match[0]); } lastIndex = match.index + match[0].length; } if (lastIndex < answer.length) { parts.push(answer.slice(lastIndex)); } return parts; } export default function Ask({ navigate }: PageProps) { const [query, setQuery] = useState(""); const [conversation, setConversation] = useState([]); const [capabilities, setCapabilities] = useState( null ); const [collections, setCollections] = useState([]); const [thoroughness, setThoroughness] = useState("balanced"); const [activePreset, setActivePreset] = useState("slim-tuned"); const [verify, setVerify] = useState(false); const [showAdvanced, setShowAdvanced] = useState( Boolean(new URLSearchParams(window.location.search).get("filter")) ); const [selectedCollection, setSelectedCollection] = useState( new URLSearchParams(window.location.search).get("collection") ?? "" ); const [intent, setIntent] = useState(""); const [candidateLimit, setCandidateLimit] = useState(""); const [exclude, setExclude] = useState(""); const [since, setSince] = useState(""); const [until, setUntil] = useState(""); const [category, setCategory] = useState(""); const [metadataFilter, setMetadataFilter] = useState( new URLSearchParams(window.location.search).get("filter") ?? "" ); const [author, setAuthor] = useState(""); const [tagMode, setTagMode] = useState("any"); const [tagsInput, setTagsInput] = useState(""); const [queryModes, setQueryModes] = useState([]); const [queryModeDraft, setQueryModeDraft] = useState("term"); const [queryModeText, setQueryModeText] = useState(""); const [queryModeError, setQueryModeError] = useState(null); const messagesEndRef = useRef(null); const textareaRef = useRef(null); 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; useEffect(() => { async function bootstrap(): Promise { const [capsResult, collectionsResult, presetsResult] = await Promise.all([ fetchServerCapabilities(), apiFetch("/api/collections"), apiFetch("/api/presets"), ]); if (capsResult.data) { const caps = capsResult.data; setCapabilities(caps); setThoroughness(caps.hybrid ? "balanced" : "fast"); } if (collectionsResult.data) { setCollections(collectionsResult.data); } if (presetsResult.data?.activePreset) { setActivePreset(presetsResult.data.activePreset); } } void bootstrap(); }, []); useEffect(() => { const url = new URL(window.location.href); if (selectedCollection.trim()) url.searchParams.set("collection", selectedCollection.trim()); else url.searchParams.delete("collection"); if (metadataFilter) url.searchParams.set("filter", metadataFilter); else url.searchParams.delete("filter"); window.history.replaceState({}, "", url.toString()); }, [metadataFilter, selectedCollection]); 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] ?? current; }); }, [hybridAvailable]); const shortcuts = useMemo( () => [{ key: "t", action: cycleThoroughness }], [cycleThoroughness] ); useKeyboardShortcuts(shortcuts); useEffect(() => { messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }); }, [conversation]); 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 handleSubmit = useCallback( async (e: React.FormEvent) => { e.preventDefault(); if (!query.trim()) { return; } const parsedFilter = parseMetadataFilter(metadataFilter); if (parsedFilter.error) { setQueryModeError(parsedFilter.error); setShowAdvanced(true); return; } if (!structuredQueryState.ok) { setQueryModeError(structuredQueryState.error.message); setShowAdvanced(true); return; } setQueryModeError(null); const entryId = crypto.randomUUID(); const currentQuery = query.trim(); setConversation((prev) => [ ...prev, { id: entryId, query: currentQuery, response: null, loading: true }, ]); setQuery(""); const depthPolicy = resolveDepthPolicy({ presetId: activePreset, fast: thoroughness === "fast", thorough: thoroughness === "thorough", hasStructuredModes: queryModes.length > 0, candidateLimit: candidateLimit.trim() ? Number(candidateLimit) : undefined, }); const requestBody: Record = { query: currentQuery, limit: 5, verify, }; if (parsedFilter.filter) requestBody.filter = parsedFilter.filter; if (selectedCollection) { requestBody.collection = selectedCollection; } if (intent.trim()) { requestBody.intent = intent.trim(); } if (depthPolicy.candidateLimit !== undefined) { requestBody.candidateLimit = depthPolicy.candidateLimit; } if (exclude.trim()) { requestBody.exclude = exclude.trim(); } if (since) { requestBody.since = since; } if (until) { requestBody.until = until; } if (category.trim()) { requestBody.category = category.trim(); } if (author.trim()) { requestBody.author = author.trim(); } const normalizedTags = parseTagsCsv(tagsInput); if (normalizedTags.length > 0) { if (tagMode === "all") { requestBody.tagsAll = normalizedTags.join(","); } else { requestBody.tagsAny = normalizedTags.join(","); } } requestBody.noExpand = depthPolicy.noExpand; requestBody.noRerank = depthPolicy.noRerank; if (queryModes.length > 0) { requestBody.queryModes = queryModes; } const { data, error } = await apiFetch("/api/ask", { method: "POST", body: JSON.stringify(requestBody), }); setConversation((prev) => prev.map((entry) => entry.id === entryId ? { ...entry, response: data ?? null, loading: false, error: error ?? undefined, } : entry ) ); }, [ metadataFilter, author, candidateLimit, activePreset, category, exclude, intent, query, queryModes, selectedCollection, since, structuredQueryState, tagMode, tagsInput, thoroughness, until, verify, ] ); const handleKeyDown = (e: React.KeyboardEvent) => { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); void handleSubmit(e); } }; const clearFilters = () => { setSelectedCollection(""); setIntent(""); setCandidateLimit(""); setExclude(""); setSince(""); setUntil(""); setCategory(""); setAuthor(""); setMetadataFilter(""); setTagsInput(""); setTagMode("any"); setQueryModes([]); setQueryModeText(""); setQueryModeError(null); }; const browseSelectedCollection = () => { if (!selectedCollection) { navigate("/browse"); return; } navigate(`/browse?collection=${encodeURIComponent(selectedCollection)}`); }; const answerAvailable = capabilities?.answer ?? false; const activeFilterPills = [ selectedCollection ? `collection:${selectedCollection}` : null, intent.trim() ? `intent:${intent.trim()}` : null, candidateLimit.trim() ? `candidates:${candidateLimit.trim()}` : null, exclude.trim() ? `exclude:${exclude.trim()}` : null, queryModes.length > 0 ? `${queryModes.length} query mode(s)` : 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, parseTagsCsv(tagsInput).length > 0 ? `${tagMode}:${parseTagsCsv(tagsInput).join(",")}` : null, verify ? "verified" : null, ].filter((pill): pill is string => Boolean(pill)); return (

Ask

{capabilities && (
{capabilities.vector && ( vectors )} {capabilities.answer && ( AI )}
)}

Collection

Author

setAuthor(e.target.value)} placeholder="gordon" value={author} />

Intent

setIntent(e.target.value)} placeholder="Disambiguate ambiguous questions without searching on this text" value={intent} />

Exclude

setExclude(e.target.value)} placeholder="team reviews, hiring, onboarding" value={exclude} />

Category

setCategory(e.target.value)} placeholder="engineering, research" value={category} />

Since

setSince(e.target.value)} type="date" value={since} />

Until

setUntil(e.target.value)} type="date" value={until} />

Candidate limit

setCandidateLimit(e.target.value)} placeholder="20" type="number" value={candidateLimit} />

Tags (comma separated)

setTagsInput(e.target.value)} placeholder="project/alpha, urgent" value={tagsInput} />
Tag match:

Query modes (term, intent, hyde)

setQueryModeText(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); handleAddQueryMode(); } }} placeholder="Add query mode text" value={queryModeText} />
{queryModeError && (

{queryModeError}

)} {queryModes.length > 0 && (
{queryModes.map((queryMode, index) => ( ))}
)}
{activeFilterPills.length > 0 && (
Filters: {activeFilterPills.map((pill) => ( {pill} ))}
)} {conversation.length === 0 && (

Ask anything

{answerAvailable ? "Get AI-powered answers with citations from your documents" : "AI answers not available. Install a generation model to enable."}

)} {conversation.map((entry) => (

{entry.query}

{entry.loading && (
Thinking...
)} {entry.error && ( {entry.error} )} {entry.response && ( <> {entry.response.meta.warnings?.map((warning) => (

{warning.message}

))} {entry.response.answer && (

{renderAnswer( entry.response.answer, entry.response.citations ?? [], navigate )}

)} {entry.response.citations && entry.response.citations.length > 0 && ( {entry.response.citations.map((citation, i) => ( { event.preventDefault(); navigate( `/doc?uri=${encodeURIComponent(citation.uri)}` ); }} title={`[${i + 1}] ${citation.uri.split("/").pop()}`} > [{i + 1}] {citation.uri.split("/").pop()} {citation.startLine && ( L{citation.startLine} {citation.endLine && `-${citation.endLine}`} )} ))} )} {entry.response.verification && ( )}
{entry.response.verification ? `${entry.response.verification.capsule.evidence.length} evidence spans` : `${entry.response.results.length} results`} {entry.response.meta.vectorsUsed && ( hybrid )} {entry.response.meta.expanded && ( expanded )} {entry.response.meta.queryModes && (entry.response.meta.queryModes.term > 0 || entry.response.meta.queryModes.intent > 0 || entry.response.meta.queryModes.hyde) && ( query modes )}
{!entry.response.answer && entry.response.results.length > 0 && (

Search results:

{entry.response.results.map((result, i) => ( navigate( `/doc?uri=${encodeURIComponent(result.uri)}` ) } >

{result.title || result.uri.split("/").pop()}

{result.snippet.slice(0, 200)}...

{(result.score * 100).toFixed(0)}%
))}
)} {!entry.response.answer && entry.response.results.length === 0 && (
No relevant results found
)} )}
))}