284 lines
11 KiB
TypeScript
284 lines
11 KiB
TypeScript
'use client';
|
|
|
|
import Link from 'next/link';
|
|
import { Suspense, useEffect, useMemo, useState, useTransition } from 'react';
|
|
import { useSearchParams } from 'next/navigation';
|
|
import { useQueryClient } from '@tanstack/react-query';
|
|
import { BrainCircuit, ExternalLink, Search as SearchIcon } from 'lucide-react';
|
|
import { AppShell } from '@/components/shell/app-shell';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Input } from '@/components/ui/input';
|
|
import { Panel } from '@/components/ui/panel';
|
|
import { useAuthGuard } from '@/hooks/use-auth-guard';
|
|
import { ensureTickerAutomation, getSearchAnswer } from '@/lib/api';
|
|
import { searchQueryOptions } from '@/lib/query/options';
|
|
import type { SearchAnswerResponse, SearchResult, SearchSource } from '@/lib/types';
|
|
|
|
const SOURCE_OPTIONS: Array<{ value: SearchSource; label: string }> = [
|
|
{ value: 'documents', label: 'Documents' },
|
|
{ value: 'filings', label: 'Filing briefs' },
|
|
{ value: 'research', label: 'Research notes' }
|
|
];
|
|
|
|
function parseSourceParams(value: string | null) {
|
|
if (!value) {
|
|
return ['documents', 'filings', 'research'] as SearchSource[];
|
|
}
|
|
|
|
const normalized = value
|
|
.split(',')
|
|
.map((entry) => entry.trim().toLowerCase())
|
|
.filter((entry): entry is SearchSource => entry === 'documents' || entry === 'filings' || entry === 'research');
|
|
|
|
return normalized.length > 0 ? [...new Set(normalized)] : ['documents', 'filings', 'research'] as SearchSource[];
|
|
}
|
|
|
|
export default function SearchPage() {
|
|
return (
|
|
<Suspense fallback={<div className="flex min-h-screen items-center justify-center text-sm text-[color:var(--terminal-muted)]">Loading search desk...</div>}>
|
|
<SearchPageContent />
|
|
</Suspense>
|
|
);
|
|
}
|
|
|
|
function SearchPageContent() {
|
|
const { isPending, isAuthenticated } = useAuthGuard();
|
|
const searchParams = useSearchParams();
|
|
const queryClient = useQueryClient();
|
|
|
|
const initialQuery = searchParams.get('q')?.trim() ?? '';
|
|
const initialTicker = searchParams.get('ticker')?.trim().toUpperCase() ?? '';
|
|
const initialSources = useMemo(() => parseSourceParams(searchParams.get('sources')), [searchParams]);
|
|
|
|
const [queryInput, setQueryInput] = useState(initialQuery);
|
|
const [query, setQuery] = useState(initialQuery);
|
|
const [tickerInput, setTickerInput] = useState(initialTicker);
|
|
const [ticker, setTicker] = useState(initialTicker);
|
|
const [sources, setSources] = useState<SearchSource[]>(initialSources);
|
|
const [results, setResults] = useState<SearchResult[]>([]);
|
|
const [answer, setAnswer] = useState<SearchAnswerResponse | null>(null);
|
|
const [loading, setLoading] = useState(false);
|
|
const [answerLoading, startAnswerTransition] = useTransition();
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
useEffect(() => {
|
|
setQueryInput(initialQuery);
|
|
setQuery(initialQuery);
|
|
setTickerInput(initialTicker);
|
|
setTicker(initialTicker);
|
|
setSources(initialSources);
|
|
}, [initialQuery, initialTicker, initialSources]);
|
|
|
|
useEffect(() => {
|
|
if (!query.trim() || !isAuthenticated) {
|
|
setResults([]);
|
|
return;
|
|
}
|
|
|
|
let cancelled = false;
|
|
setLoading(true);
|
|
setError(null);
|
|
|
|
queryClient.fetchQuery(searchQueryOptions({
|
|
query,
|
|
ticker: ticker || undefined,
|
|
sources,
|
|
limit: 10
|
|
})).then((response) => {
|
|
if (!cancelled) {
|
|
setResults(response.results);
|
|
}
|
|
}).catch((err) => {
|
|
if (!cancelled) {
|
|
setError(err instanceof Error ? err.message : 'Unable to search indexed sources');
|
|
setResults([]);
|
|
}
|
|
}).finally(() => {
|
|
if (!cancelled) {
|
|
setLoading(false);
|
|
}
|
|
});
|
|
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, [isAuthenticated, query, queryClient, sources, ticker]);
|
|
|
|
if (isPending || !isAuthenticated) {
|
|
return <div className="flex min-h-screen items-center justify-center text-sm text-[color:var(--terminal-muted)]">Loading search desk...</div>;
|
|
}
|
|
|
|
const runAnswer = () => {
|
|
if (!query.trim()) {
|
|
return;
|
|
}
|
|
|
|
if (ticker.trim()) {
|
|
void ensureTickerAutomation({
|
|
ticker,
|
|
source: 'search'
|
|
});
|
|
}
|
|
|
|
startAnswerTransition(() => {
|
|
setError(null);
|
|
getSearchAnswer({
|
|
query,
|
|
ticker: ticker || undefined,
|
|
sources,
|
|
limit: 10
|
|
}).then((response) => {
|
|
setAnswer(response);
|
|
}).catch((err) => {
|
|
setError(err instanceof Error ? err.message : 'Unable to generate cited answer');
|
|
setAnswer(null);
|
|
});
|
|
});
|
|
};
|
|
|
|
return (
|
|
<AppShell
|
|
title="Search"
|
|
subtitle="Hybrid semantic + lexical retrieval across primary filings, filing briefs, and private research notes."
|
|
activeTicker={ticker || null}
|
|
>
|
|
<div className="grid grid-cols-1 gap-6 xl:grid-cols-[1.2fr_0.8fr]">
|
|
<Panel title="Search Query" subtitle="Run semantic search with an optional ticker filter and source selection.">
|
|
<form
|
|
className="space-y-3"
|
|
onSubmit={(event) => {
|
|
event.preventDefault();
|
|
const normalizedTicker = tickerInput.trim().toUpperCase();
|
|
if (normalizedTicker) {
|
|
void ensureTickerAutomation({
|
|
ticker: normalizedTicker,
|
|
source: 'search'
|
|
});
|
|
}
|
|
setQuery(queryInput.trim());
|
|
setTicker(normalizedTicker);
|
|
setAnswer(null);
|
|
}}
|
|
>
|
|
<Input
|
|
value={queryInput}
|
|
onChange={(event) => setQueryInput(event.target.value)}
|
|
placeholder="Ask about margin drivers, segment commentary, risks, or your notes..."
|
|
/>
|
|
<div className="flex flex-col gap-3 sm:flex-row">
|
|
<Input
|
|
value={tickerInput}
|
|
onChange={(event) => setTickerInput(event.target.value.toUpperCase())}
|
|
placeholder="Ticker filter (optional)"
|
|
className="sm:max-w-xs"
|
|
/>
|
|
<div className="flex flex-wrap gap-2">
|
|
{SOURCE_OPTIONS.map((option) => {
|
|
const selected = sources.includes(option.value);
|
|
return (
|
|
<Button
|
|
key={option.value}
|
|
type="button"
|
|
variant={selected ? 'primary' : 'ghost'}
|
|
className="px-2 py-1 text-xs"
|
|
onClick={() => {
|
|
setSources((current) => {
|
|
if (selected && current.length > 1) {
|
|
return current.filter((entry) => entry !== option.value);
|
|
}
|
|
|
|
return selected ? current : [...current, option.value];
|
|
});
|
|
}}
|
|
>
|
|
{option.label}
|
|
</Button>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
<div className="flex flex-wrap gap-2">
|
|
<Button type="submit" className="w-full sm:w-auto">
|
|
<SearchIcon className="size-4" />
|
|
Search
|
|
</Button>
|
|
<Button type="button" variant="secondary" className="w-full sm:w-auto" onClick={runAnswer} disabled={!query.trim() || answerLoading}>
|
|
<BrainCircuit className="size-4" />
|
|
{answerLoading ? 'Answering...' : 'Cited answer'}
|
|
</Button>
|
|
</div>
|
|
</form>
|
|
</Panel>
|
|
|
|
<Panel title="Cited Answer" subtitle="Single-turn answer grounded only in retrieved evidence." variant="surface">
|
|
{answer ? (
|
|
<div className="space-y-3">
|
|
<p className="whitespace-pre-wrap text-sm leading-7 text-[color:var(--terminal-bright)]">{answer.answer}</p>
|
|
{answer.citations.length > 0 ? (
|
|
<div className="space-y-2">
|
|
{answer.citations.map((citation) => (
|
|
<Link
|
|
key={`${citation.chunkId}-${citation.index}`}
|
|
href={citation.href}
|
|
className="flex items-center justify-between rounded-lg border border-[color:var(--line-weak)] bg-[color:var(--panel-soft)] px-3 py-2 text-sm text-[color:var(--terminal-bright)] transition hover:border-[color:var(--line-strong)]"
|
|
>
|
|
<span>[{citation.index}] {citation.label}</span>
|
|
<ExternalLink className="size-3.5 text-[color:var(--accent)]" />
|
|
</Link>
|
|
))}
|
|
</div>
|
|
) : (
|
|
<p className="text-sm text-[color:var(--terminal-muted)]">No supporting citations were strong enough to answer.</p>
|
|
)}
|
|
</div>
|
|
) : (
|
|
<p className="text-sm text-[color:var(--terminal-muted)]">Ask a question to synthesize the top retrieved passages into a cited answer.</p>
|
|
)}
|
|
</Panel>
|
|
</div>
|
|
|
|
<Panel
|
|
title="Semantic Search"
|
|
subtitle={query ? `${results.length} results${ticker ? ` for ${ticker}` : ''}.` : 'Search results will appear here.'}
|
|
variant="surface"
|
|
>
|
|
{error ? <p className="text-sm text-[#ffb5b5]">{error}</p> : null}
|
|
{loading ? (
|
|
<p className="text-sm text-[color:var(--terminal-muted)]">Searching indexed sources...</p>
|
|
) : !query ? (
|
|
<p className="text-sm text-[color:var(--terminal-muted)]">Enter a question or topic to search the local RAG index.</p>
|
|
) : results.length === 0 ? (
|
|
<p className="text-sm text-[color:var(--terminal-muted)]">No indexed evidence matched this query.</p>
|
|
) : (
|
|
<div className="space-y-3">
|
|
{results.map((result) => (
|
|
<article key={result.chunkId} className="rounded-xl border border-[color:var(--line-weak)] bg-[color:var(--panel-soft)] p-4">
|
|
<div className="flex flex-wrap items-start justify-between gap-3">
|
|
<div>
|
|
<p className="text-xs uppercase tracking-[0.16em] text-[color:var(--terminal-muted)]">
|
|
{result.source} {result.ticker ? `· ${result.ticker}` : ''} {result.filingDate ? `· ${result.filingDate}` : ''}
|
|
</p>
|
|
<h3 className="mt-1 text-sm font-semibold text-[color:var(--terminal-bright)]">{result.title ?? result.citationLabel}</h3>
|
|
<p className="mt-1 text-xs text-[color:var(--accent)]">{result.citationLabel}</p>
|
|
</div>
|
|
<Link
|
|
href={result.href}
|
|
className="inline-flex items-center gap-1 rounded-md border border-[color:var(--line-weak)] px-2 py-1 text-xs text-[color:var(--accent)] transition hover:border-[color:var(--line-strong)] hover:text-[color:var(--accent-strong)]"
|
|
>
|
|
Open source
|
|
<ExternalLink className="size-3" />
|
|
</Link>
|
|
</div>
|
|
{result.headingPath ? (
|
|
<p className="mt-3 text-xs uppercase tracking-[0.12em] text-[color:var(--terminal-muted)]">{result.headingPath}</p>
|
|
) : null}
|
|
<p className="mt-3 text-sm leading-6 text-[color:var(--terminal-bright)]">{result.snippet}</p>
|
|
</article>
|
|
))}
|
|
</div>
|
|
)}
|
|
</Panel>
|
|
</AppShell>
|
|
);
|
|
}
|