Add hybrid research copilot workspace
This commit is contained in:
444
components/research/research-copilot-panel.tsx
Normal file
444
components/research/research-copilot-panel.tsx
Normal file
@@ -0,0 +1,444 @@
|
||||
'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" />;
|
||||
}
|
||||
Reference in New Issue
Block a user