445 lines
18 KiB
TypeScript
445 lines
18 KiB
TypeScript
'use client';
|
|
|
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
|
import {
|
|
BrainCircuit,
|
|
ExternalLink,
|
|
FileText,
|
|
Link2,
|
|
LoaderCircle,
|
|
Pin,
|
|
PinOff,
|
|
Sparkles
|
|
} from 'lucide-react';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Panel } from '@/components/ui/panel';
|
|
import { Input } from '@/components/ui/input';
|
|
import { queueResearchCopilotJob, runResearchCopilotTurn } from '@/lib/api';
|
|
import type {
|
|
ResearchCopilotCitation,
|
|
ResearchCopilotSession,
|
|
ResearchCopilotSuggestedAction,
|
|
ResearchMemoSection,
|
|
SearchSource
|
|
} from '@/lib/types';
|
|
|
|
type ResearchCopilotPanelProps = {
|
|
ticker: string;
|
|
companyName: string | null;
|
|
session: ResearchCopilotSession | null;
|
|
targetSection: ResearchMemoSection;
|
|
variant?: 'docked' | 'focus';
|
|
onSessionChange: (session: ResearchCopilotSession) => void;
|
|
onDraftNote: (input: { title: string; summary: string; bodyMarkdown: string }) => void;
|
|
onDraftMemoSection: (section: ResearchMemoSection, contentMarkdown: string) => void;
|
|
onAttachCitation: (citation: ResearchCopilotCitation, section: ResearchMemoSection) => Promise<void>;
|
|
onNotice: (message: string) => void;
|
|
onError: (message: string) => void;
|
|
};
|
|
|
|
const DEFAULT_SOURCES: SearchSource[] = ['documents', 'filings', 'research'];
|
|
const SOURCE_OPTIONS: Array<{ value: SearchSource; label: string }> = [
|
|
{ value: 'documents', label: 'Documents' },
|
|
{ value: 'filings', label: 'Filing briefs' },
|
|
{ value: 'research', label: 'Research notes' }
|
|
];
|
|
|
|
type OptimisticMessage = {
|
|
id: string;
|
|
role: 'user';
|
|
content_markdown: string;
|
|
};
|
|
|
|
function draftStorageKey(ticker: string) {
|
|
return `research-copilot-draft:${ticker}`;
|
|
}
|
|
|
|
function toSummary(markdown: string) {
|
|
const normalized = markdown.replace(/\s+/g, ' ').trim();
|
|
return normalized.length > 180 ? `${normalized.slice(0, 177).trimEnd()}...` : normalized;
|
|
}
|
|
|
|
function buildSuggestedPrompts(companyName: string | null, ticker: string) {
|
|
const label = companyName ?? ticker;
|
|
return [
|
|
`What changed in the latest evidence set for ${label}?`,
|
|
`Draft a thesis update for ${ticker} with the strongest citations.`,
|
|
`What evidence most directly challenges the current bull case?`
|
|
];
|
|
}
|
|
|
|
export function ResearchCopilotPanel({
|
|
ticker,
|
|
companyName,
|
|
session,
|
|
targetSection,
|
|
variant = 'docked',
|
|
onSessionChange,
|
|
onDraftNote,
|
|
onDraftMemoSection,
|
|
onAttachCitation,
|
|
onNotice,
|
|
onError
|
|
}: ResearchCopilotPanelProps) {
|
|
const scrollRef = useRef<HTMLDivElement | null>(null);
|
|
const [prompt, setPrompt] = useState('');
|
|
const [selectedSources, setSelectedSources] = useState<SearchSource[]>(session?.selected_sources ?? DEFAULT_SOURCES);
|
|
const [pinnedArtifactIds, setPinnedArtifactIds] = useState<number[]>(session?.pinned_artifact_ids ?? []);
|
|
const [optimisticMessages, setOptimisticMessages] = useState<OptimisticMessage[]>([]);
|
|
const [submitting, setSubmitting] = useState(false);
|
|
const [queueing, setQueueing] = useState(false);
|
|
|
|
useEffect(() => {
|
|
setSelectedSources(session?.selected_sources ?? DEFAULT_SOURCES);
|
|
setPinnedArtifactIds(session?.pinned_artifact_ids ?? []);
|
|
}, [session?.id, session?.updated_at]);
|
|
|
|
useEffect(() => {
|
|
const saved = typeof window === 'undefined' ? null : window.localStorage.getItem(draftStorageKey(ticker));
|
|
setPrompt(saved ?? '');
|
|
}, [ticker]);
|
|
|
|
useEffect(() => {
|
|
if (typeof window === 'undefined') {
|
|
return;
|
|
}
|
|
|
|
const timeout = window.setTimeout(() => {
|
|
window.localStorage.setItem(draftStorageKey(ticker), prompt);
|
|
}, 250);
|
|
|
|
return () => window.clearTimeout(timeout);
|
|
}, [prompt, ticker]);
|
|
|
|
useEffect(() => {
|
|
if (!scrollRef.current) {
|
|
return;
|
|
}
|
|
|
|
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
|
|
}, [session?.updated_at, optimisticMessages.length, submitting]);
|
|
|
|
const messages = useMemo(() => {
|
|
const persisted = session?.messages ?? [];
|
|
const optimistic = optimisticMessages.map((message) => ({
|
|
id: -1,
|
|
session_id: session?.id ?? 0,
|
|
user_id: '',
|
|
role: message.role,
|
|
content_markdown: message.content_markdown,
|
|
citations: [],
|
|
follow_ups: [],
|
|
suggested_actions: [],
|
|
selected_sources: selectedSources,
|
|
pinned_artifact_ids: pinnedArtifactIds,
|
|
memo_section: targetSection,
|
|
created_at: new Date().toISOString()
|
|
}));
|
|
|
|
return [...persisted, ...optimistic];
|
|
}, [optimisticMessages, pinnedArtifactIds, selectedSources, session, targetSection]);
|
|
|
|
const sendTurn = async () => {
|
|
const query = prompt.trim();
|
|
if (!query || submitting) {
|
|
return;
|
|
}
|
|
|
|
const optimisticMessage: OptimisticMessage = {
|
|
id: `${Date.now()}`,
|
|
role: 'user',
|
|
content_markdown: query
|
|
};
|
|
setOptimisticMessages((current) => [...current, optimisticMessage]);
|
|
setSubmitting(true);
|
|
|
|
try {
|
|
const response = await runResearchCopilotTurn({
|
|
ticker,
|
|
query,
|
|
sources: selectedSources,
|
|
pinnedArtifactIds,
|
|
memoSection: targetSection
|
|
});
|
|
setPrompt('');
|
|
setOptimisticMessages([]);
|
|
if (typeof window !== 'undefined') {
|
|
window.localStorage.removeItem(draftStorageKey(ticker));
|
|
}
|
|
onSessionChange(response.session);
|
|
} catch (error) {
|
|
setOptimisticMessages([]);
|
|
onError(error instanceof Error ? error.message : 'Unable to run copilot turn');
|
|
} finally {
|
|
setSubmitting(false);
|
|
}
|
|
};
|
|
|
|
const queueBrief = async (queryOverride?: string | null) => {
|
|
const query = queryOverride?.trim() || prompt.trim();
|
|
if (!query || queueing) {
|
|
return;
|
|
}
|
|
|
|
setQueueing(true);
|
|
try {
|
|
await queueResearchCopilotJob({
|
|
ticker,
|
|
query,
|
|
sources: selectedSources
|
|
});
|
|
onNotice('Queued research brief. Track progress in notifications.');
|
|
} catch (error) {
|
|
onError(error instanceof Error ? error.message : 'Unable to queue research brief');
|
|
} finally {
|
|
setQueueing(false);
|
|
}
|
|
};
|
|
|
|
const applySuggestedAction = async (action: ResearchCopilotSuggestedAction) => {
|
|
if (action.type === 'draft_note' && action.content_markdown) {
|
|
onDraftNote({
|
|
title: action.title ?? `${ticker} copilot draft`,
|
|
summary: toSummary(action.content_markdown),
|
|
bodyMarkdown: action.content_markdown
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (action.type === 'draft_memo_section' && action.section && action.content_markdown) {
|
|
onDraftMemoSection(action.section, action.content_markdown);
|
|
return;
|
|
}
|
|
|
|
if (action.type === 'queue_research_brief') {
|
|
await queueBrief(action.query);
|
|
}
|
|
};
|
|
|
|
const suggestedPrompts = buildSuggestedPrompts(companyName, ticker);
|
|
const panelClassName = variant === 'focus'
|
|
? 'min-h-[70vh]'
|
|
: 'xl:sticky xl:top-6 self-start';
|
|
|
|
return (
|
|
<Panel
|
|
className={panelClassName}
|
|
title={variant === 'focus' ? 'Copilot Focus' : 'Research Copilot'}
|
|
subtitle="Cited answers, follow-up questions, and draft actions tied directly to the current company workspace."
|
|
actions={<BrainCircuit className="size-4 text-[color:var(--accent)]" />}
|
|
>
|
|
<div className="space-y-4">
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
<span className="rounded-full border border-[color:var(--line-strong)] bg-[color:var(--panel)] px-2 py-1 text-[10px] uppercase tracking-[0.16em] text-[color:var(--accent)]">
|
|
{ticker}
|
|
</span>
|
|
<span className="rounded-full border border-[color:var(--line-weak)] bg-[color:var(--panel-soft)] px-2 py-1 text-[10px] uppercase tracking-[0.16em] text-[color:var(--terminal-muted)]">
|
|
Target: {targetSection.replace('_', ' ')}
|
|
</span>
|
|
{pinnedArtifactIds.length > 0 ? (
|
|
<span className="rounded-full border border-[color:var(--line-weak)] bg-[color:var(--panel-soft)] px-2 py-1 text-[10px] uppercase tracking-[0.16em] text-[color:var(--terminal-muted)]">
|
|
{pinnedArtifactIds.length} pinned
|
|
</span>
|
|
) : null}
|
|
</div>
|
|
|
|
<div className="flex flex-wrap gap-2">
|
|
{SOURCE_OPTIONS.map((option) => {
|
|
const selected = selectedSources.includes(option.value);
|
|
return (
|
|
<Button
|
|
key={option.value}
|
|
type="button"
|
|
variant={selected ? 'primary' : 'ghost'}
|
|
className="px-2 py-1 text-xs"
|
|
onClick={() => {
|
|
setSelectedSources((current) => {
|
|
if (selected && current.length > 1) {
|
|
return current.filter((entry) => entry !== option.value);
|
|
}
|
|
|
|
return selected ? current : [...current, option.value];
|
|
});
|
|
}}
|
|
>
|
|
{option.label}
|
|
</Button>
|
|
);
|
|
})}
|
|
</div>
|
|
|
|
<div
|
|
ref={scrollRef}
|
|
className={variant === 'focus'
|
|
? 'max-h-[52vh] overflow-y-auto rounded-2xl border border-[color:var(--line-weak)] bg-[color:var(--panel-soft)] p-4'
|
|
: 'max-h-[36rem] overflow-y-auto rounded-2xl border border-[color:var(--line-weak)] bg-[color:var(--panel-soft)] p-4'
|
|
}
|
|
>
|
|
{messages.length === 0 ? (
|
|
<div className="space-y-3">
|
|
<p className="text-sm text-[color:var(--terminal-muted)]">
|
|
Ask for cited thesis changes, memo updates, risk summaries, or evidence gaps. This copilot uses the existing search index and current research context.
|
|
</p>
|
|
<div className="flex flex-wrap gap-2">
|
|
{suggestedPrompts.map((suggestion) => (
|
|
<button
|
|
key={suggestion}
|
|
type="button"
|
|
className="rounded-xl border border-[color:var(--line-weak)] bg-[color:var(--panel)] px-3 py-2 text-left text-xs text-[color:var(--terminal-muted)] transition hover:border-[color:var(--line-strong)] hover:text-[color:var(--terminal-bright)]"
|
|
onClick={() => setPrompt(suggestion)}
|
|
>
|
|
{suggestion}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<div className="space-y-4">
|
|
{messages.map((message, index) => (
|
|
<article
|
|
key={`${message.role}-${message.created_at}-${index}`}
|
|
className={message.role === 'assistant'
|
|
? 'rounded-2xl border border-[color:var(--line-weak)] bg-[color:var(--panel)] p-4'
|
|
: 'rounded-2xl border border-[color:var(--line-weak)] bg-[rgba(255,255,255,0.03)] p-4'
|
|
}
|
|
>
|
|
<div className="flex items-center justify-between gap-2">
|
|
<p className="text-[11px] uppercase tracking-[0.16em] text-[color:var(--terminal-muted)]">
|
|
{message.role === 'assistant' ? 'Copilot' : 'You'}
|
|
</p>
|
|
{message.role === 'assistant' ? <Sparkles className="size-4 text-[color:var(--accent)]" /> : null}
|
|
</div>
|
|
<p className="mt-3 whitespace-pre-wrap text-sm leading-7 text-[color:var(--terminal-bright)]">
|
|
{message.content_markdown}
|
|
</p>
|
|
|
|
{message.citations.length > 0 ? (
|
|
<div className="mt-4 space-y-2">
|
|
{message.citations.map((citation) => {
|
|
const pinned = citation.artifactId !== null && pinnedArtifactIds.includes(citation.artifactId);
|
|
|
|
return (
|
|
<div key={`${message.id}-${citation.index}`} className="rounded-xl border border-[color:var(--line-weak)] bg-[color:var(--panel-soft)] p-3">
|
|
<div className="flex flex-wrap items-start justify-between gap-2">
|
|
<div>
|
|
<p className="text-xs text-[color:var(--accent)]">[{citation.index}] {citation.label}</p>
|
|
<p className="mt-1 text-xs text-[color:var(--terminal-muted)]">{citation.excerpt}</p>
|
|
</div>
|
|
<div className="flex flex-wrap gap-2">
|
|
<a
|
|
href={citation.href}
|
|
className="inline-flex items-center gap-1 rounded-lg border border-[color:var(--line-weak)] px-2 py-1 text-xs text-[color:var(--accent)] transition hover:border-[color:var(--line-strong)]"
|
|
>
|
|
Open
|
|
<ExternalLink className="size-3" />
|
|
</a>
|
|
{citation.artifactId ? (
|
|
<Button
|
|
variant="ghost"
|
|
className="px-2 py-1 text-xs"
|
|
onClick={() => {
|
|
setPinnedArtifactIds((current) => pinned
|
|
? current.filter((entry) => entry !== citation.artifactId)
|
|
: [...current, citation.artifactId!]);
|
|
}}
|
|
>
|
|
{pinned ? <PinOff className="size-3" /> : <Pin className="size-3" />}
|
|
{pinned ? 'Unpin' : 'Pin'}
|
|
</Button>
|
|
) : null}
|
|
<Button
|
|
variant="ghost"
|
|
className="px-2 py-1 text-xs"
|
|
onClick={() => {
|
|
void onAttachCitation(citation, targetSection);
|
|
}}
|
|
disabled={!citation.artifactId}
|
|
>
|
|
<Link2 className="size-3" />
|
|
Attach
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
) : null}
|
|
|
|
{message.role === 'assistant' && message.suggested_actions.length > 0 ? (
|
|
<div className="mt-4 flex flex-wrap gap-2">
|
|
{message.suggested_actions.map((action) => (
|
|
<Button
|
|
key={action.id}
|
|
variant={action.type === 'queue_research_brief' ? 'secondary' : 'ghost'}
|
|
className="px-2 py-1 text-xs"
|
|
onClick={() => {
|
|
void applySuggestedAction(action);
|
|
}}
|
|
>
|
|
{action.type === 'draft_note' ? <NotebookIcon /> : action.type === 'draft_memo_section' ? <FileText className="size-3" /> : <Sparkles className="size-3" />}
|
|
{action.label}
|
|
</Button>
|
|
))}
|
|
</div>
|
|
) : null}
|
|
|
|
{message.role === 'assistant' && message.follow_ups.length > 0 ? (
|
|
<div className="mt-4 flex flex-wrap gap-2">
|
|
{message.follow_ups.map((followUp) => (
|
|
<button
|
|
key={followUp}
|
|
type="button"
|
|
className="rounded-full border border-[color:var(--line-weak)] px-2 py-1 text-[11px] text-[color:var(--terminal-muted)] transition hover:border-[color:var(--line-strong)] hover:text-[color:var(--terminal-bright)]"
|
|
onClick={() => setPrompt(followUp)}
|
|
>
|
|
{followUp}
|
|
</button>
|
|
))}
|
|
</div>
|
|
) : null}
|
|
</article>
|
|
))}
|
|
</div>
|
|
)}
|
|
{submitting ? (
|
|
<div className="mt-3 flex items-center gap-2 text-xs text-[color:var(--terminal-muted)]">
|
|
<LoaderCircle className="size-3.5 animate-spin" />
|
|
Running cited research turn...
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
|
|
<div className="space-y-3 rounded-2xl border border-[color:var(--line-weak)] bg-[color:var(--panel-soft)] p-4">
|
|
<Input
|
|
aria-label="Research copilot prompt"
|
|
value={prompt}
|
|
onChange={(event) => setPrompt(event.target.value)}
|
|
placeholder="Ask for evidence-backed thesis updates, risk summaries, or draft memo language..."
|
|
onKeyDown={(event) => {
|
|
if ((event.metaKey || event.ctrlKey) && event.key === 'Enter') {
|
|
event.preventDefault();
|
|
void sendTurn();
|
|
}
|
|
}}
|
|
/>
|
|
<div className="flex flex-wrap gap-2">
|
|
<Button onClick={() => void sendTurn()} disabled={!prompt.trim() || submitting}>
|
|
<BrainCircuit className="size-4" />
|
|
{submitting ? 'Thinking...' : 'Ask copilot'}
|
|
</Button>
|
|
<Button variant="secondary" onClick={() => void queueBrief()} disabled={!prompt.trim() || queueing}>
|
|
<Sparkles className="size-4" />
|
|
{queueing ? 'Queueing...' : 'Queue brief'}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</Panel>
|
|
);
|
|
}
|
|
|
|
function NotebookIcon() {
|
|
return <FileText className="size-3" />;
|
|
}
|