diff --git a/mcp-server/index.ts b/mcp-server/index.ts index 6657928..74a918f 100644 --- a/mcp-server/index.ts +++ b/mcp-server/index.ts @@ -377,6 +377,16 @@ Use for breaking stories, current events, and time-sensitive reporting.`, 'What to do with the text (e.g. "summarise", "extract key points")', default: "summarise", }, + format: { + type: "string", + enum: ["bullets", "narrative", "table", "comparison"], + description: + 'Output format for the report. ' + + '"bullets" (default): concise bullet points with citations. ' + + '"narrative": flowing prose with inline citations. ' + + '"table": markdown table with source | claim | detail columns. ' + + '"comparison": side-by-side contrast of sources\' positions.', + }, }, required: ["text"], }, @@ -986,7 +996,11 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => { // ── ai_summarize ────────────────────────────────────────────────────── if (name === 'ai_summarize') { - const { text, instruction = 'summarise' } = args as { text: string; instruction?: string } + const { text, instruction = 'summarise', format } = args as { + text: string + instruction?: string + format?: 'bullets' | 'narrative' | 'table' | 'comparison' + } const aiClient = getGroq() if (!aiClient) { return { content: [{ type: 'text', text: 'AI summarization is not configured.' }], isError: true } @@ -1006,6 +1020,23 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => { return { content: [{ type: 'text', text: `Validation error: combined text + instruction length exceeds maximum of ${AI_COMBINED_MAX_LENGTH} characters (received ${text.length + instruction.length}).` }], isError: true } } + // ── Format-specific instruction suffix ─────────────────────────────── + // When a format is provided it overrides the instruction with a structured + // directive so the AI produces output in the requested report style. + const VALID_FORMATS = new Set(['bullets', 'narrative', 'table', 'comparison']) + const resolvedFormat = format && VALID_FORMATS.has(format) ? format : undefined + + const FORMAT_DIRECTIVES: Record = { + bullets: 'Write a concise bullet-point summary (3–7 bullets). Each bullet must cite its source(s) using [N] notation.', + narrative: 'Write a flowing prose summary (2–4 paragraphs) with inline [N] citations. Do not use bullet lists.', + table: 'Produce a Markdown table with columns: Source [N] | Key Claim | Detail. One row per source.', + comparison: 'Compare and contrast the sources\' positions: agreements first, then disagreements. Use inline [N] citations.', + } + + const userContent = resolvedFormat + ? `${FORMAT_DIRECTIVES[resolvedFormat]}\n\nText to analyse:\n\n${text}` + : `Please ${instruction} the following:\n\n${text}` + try { const completion = await aiClient.chat.completions.create({ model: 'llama-3.3-70b-versatile', @@ -1017,7 +1048,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => { }, { role: "user", - content: `Please ${instruction} the following:\n\n${text}`, + content: userContent, }, ], max_tokens: 512, diff --git a/src/components/search/ResearchWorkflowPanel.test.tsx b/src/components/search/ResearchWorkflowPanel.test.tsx new file mode 100644 index 0000000..4ced0ea --- /dev/null +++ b/src/components/search/ResearchWorkflowPanel.test.tsx @@ -0,0 +1,388 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { render, screen, act, fireEvent, waitFor } from '@testing-library/react' +import { ResearchWorkflowPanel } from './ResearchWorkflowPanel' +import type { SearchResult } from '../../hooks/useSearch' + +// ── Framer-motion stub ───────────────────────────────────────────────────── +vi.mock('framer-motion', () => ({ + motion: { + div: ({ children, ...props }: any) =>
{children}
, + }, + AnimatePresence: ({ children }: any) => <>{children}, +})) + +function stripMotion(props: Record) { + const SKIP = new Set(['initial', 'animate', 'exit', 'transition', 'whileHover', 'whileTap']) + return Object.fromEntries(Object.entries(props).filter(([k]) => !SKIP.has(k))) +} + +// ── AiMarkdown stub ──────────────────────────────────────────────────────── +vi.mock('../ai/AiMarkdown', () => ({ + AiMarkdown: ({ content }: { content: string }) =>
{content}
, +})) + +// ── Sonner stub ──────────────────────────────────────────────────────────── +vi.mock('sonner', () => ({ + toast: { info: vi.fn(), success: vi.fn(), error: vi.fn() }, +})) + +// ── Helpers ──────────────────────────────────────────────────────────────── +const makeResult = (id: string, title: string): SearchResult => ({ + id, + title, + url: `https://example.com/${id}`, + description: `Description for ${title}`, + source: 'example.com', + relevanceScore: 0.9, +}) + +const results: SearchResult[] = [ + makeResult('1', 'Stellar blockchain'), + makeResult('2', 'x402 protocol'), + makeResult('3', 'Serper.dev API'), +] + +// ── Fetch mock helpers ───────────────────────────────────────────────────── +function makeMockFetch(content: string) { + return vi.fn().mockResolvedValue({ + ok: true, + headers: { get: () => 'application/json' }, + json: async () => ({ content }), + }) +} + +describe('ResearchWorkflowPanel', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + // ── Rendering ──────────────────────────────────────────────────────── + + it('renders all result source checkboxes with correct labels', () => { + render() + + for (const r of results) { + expect(screen.getByLabelText(`Include source: ${r.title}`)).not.toBeNull() + } + }) + + it('renders all 4 format radio buttons', () => { + render() + + expect(screen.getByLabelText('BULLET POINTS')).not.toBeNull() + expect(screen.getByLabelText('NARRATIVE')).not.toBeNull() + expect(screen.getByLabelText('TABLE')).not.toBeNull() + expect(screen.getByLabelText('COMPARISON')).not.toBeNull() + }) + + it('starts with all sources checked', () => { + render() + + for (const r of results) { + const checkbox = screen.getByLabelText(`Include source: ${r.title}`) as HTMLInputElement + expect(checkbox.checked).toBe(true) + } + }) + + it('starts with bullets format selected', () => { + render() + const radio = screen.getByLabelText('BULLET POINTS') as HTMLInputElement + expect(radio.checked).toBe(true) + }) + + it('shows the source count badge', () => { + render() + // The badge renders "3/3 selected" split across text nodes — find by partial match + const badge = document.querySelector('[style*="font-size: 10px"]') + expect(badge).not.toBeNull() + expect(badge!.textContent).toContain('3') + }) + + // ── Source selection ───────────────────────────────────────────────── + + it('toggles a source off when checkbox is unchecked', async () => { + render() + + const checkbox = screen.getByLabelText('Include source: Stellar blockchain') as HTMLInputElement + expect(checkbox.checked).toBe(true) + + await act(async () => { + fireEvent.click(checkbox) + }) + + expect(checkbox.checked).toBe(false) + }) + + it('deselects all sources when NONE is clicked', async () => { + render() + + // Expand the source list first (click the header to show it) + const header = screen.getByRole('button', { name: /SOURCES/i }) + await act(async () => { + fireEvent.click(header) + }) + await act(async () => { + fireEvent.click(header) + }) + + await act(async () => { + fireEvent.click(screen.getByLabelText('Deselect all sources')) + }) + + for (const r of results) { + const cb = screen.getByLabelText(`Include source: ${r.title}`) as HTMLInputElement + expect(cb.checked).toBe(false) + } + }) + + it('re-selects all sources when ALL is clicked after deselecting', async () => { + render() + + // Open the sources panel + const headerBtn = screen.getByRole('button', { name: /SOURCES/i }) + await act(async () => { fireEvent.click(headerBtn) }) + // Open again (toggle back open) + await act(async () => { fireEvent.click(headerBtn) }) + + // Deselect all + await act(async () => { + fireEvent.click(screen.getByLabelText('Deselect all sources')) + }) + for (const r of results) { + expect((screen.getByLabelText(`Include source: ${r.title}`) as HTMLInputElement).checked).toBe(false) + } + + // Select all + await act(async () => { + fireEvent.click(screen.getByLabelText('Select all sources')) + }) + for (const r of results) { + expect((screen.getByLabelText(`Include source: ${r.title}`) as HTMLInputElement).checked).toBe(true) + } + }) + + // ── Generate button state ──────────────────────────────────────────── + + it('disables the generate button when no sources are selected', async () => { + render() + + // Expand source list to access NONE button + const headerBtn = screen.getByRole('button', { name: /SOURCES/i }) + await act(async () => { fireEvent.click(headerBtn) }) + await act(async () => { fireEvent.click(headerBtn) }) + + await act(async () => { + fireEvent.click(screen.getByLabelText('Deselect all sources')) + }) + + const btn = screen.getByRole('button', { name: /select at least one source/i }) as HTMLButtonElement + expect(btn.disabled).toBe(true) + }) + + it('enables the generate button when at least one source is selected', () => { + render() + const btn = screen.getByRole('button', { name: /generate research report/i }) as HTMLButtonElement + expect(btn.disabled).toBe(false) + }) + + // ── Format selection ───────────────────────────────────────────────── + + it('changes format when a different radio is clicked', async () => { + render() + + const narrativeRadio = screen.getByLabelText('NARRATIVE') as HTMLInputElement + await act(async () => { + fireEvent.click(narrativeRadio) + }) + expect(narrativeRadio.checked).toBe(true) + + const bulletsRadio = screen.getByLabelText('BULLET POINTS') as HTMLInputElement + expect(bulletsRadio.checked).toBe(false) + }) + + // ── Report generation (JSON response) ─────────────────────────────── + + it('calls /ai/chat and renders the report via AiMarkdown', async () => { + const mockFetch = makeMockFetch('**Findings**: Stellar is great [1].') + global.fetch = mockFetch as any + + render() + + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: /generate research report/i })) + }) + + await waitFor(() => { + expect(screen.getByTestId('ai-markdown')).not.toBeNull() + }) + expect(screen.getByTestId('ai-markdown').textContent).toContain('Findings') + expect(mockFetch).toHaveBeenCalledOnce() + }) + + it('includes all selected source data in the POST body', async () => { + const mockFetch = makeMockFetch('Report content.') + global.fetch = mockFetch as any + + render() + + // Deselect result #3 — it's in the DOM even when sources section is collapsed + await act(async () => { + fireEvent.click(screen.getByLabelText('Include source: Serper.dev API')) + }) + + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: /generate research report/i })) + }) + + await waitFor(() => expect(mockFetch).toHaveBeenCalledOnce()) + + const body = JSON.parse(mockFetch.mock.calls[0][1].body) + const prompt: string = body.messages[0].content + expect(prompt).toContain('stellar test') + expect(prompt).toContain('Stellar blockchain') + expect(prompt).toContain('x402 protocol') + // Deselected source should not be in prompt + expect(prompt).not.toContain('Serper.dev API') + }) + + it('shows report header with format name after generation', async () => { + global.fetch = makeMockFetch('Report here.') as any + + render() + + // Switch to narrative + await act(async () => { + fireEvent.click(screen.getByLabelText('NARRATIVE')) + }) + + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: /generate research report/i })) + }) + + await waitFor(() => { + const reportHeader = document.querySelector('[class*="font-display"][class*="text-neon-cyan"]') + expect(reportHeader).not.toBeNull() + }) + }) + + // ── Omitted source footer ──────────────────────────────────────────── + + it('shows omitted sources in the footer when sources are deselected', async () => { + global.fetch = makeMockFetch('Report.') as any + + render() + + // Deselect source 2 + await act(async () => { + fireEvent.click(screen.getByLabelText('Include source: x402 protocol')) + }) + + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: /generate research report/i })) + }) + + await waitFor(() => { + const footer = screen.queryByLabelText('Source status') + expect(footer).not.toBeNull() + }) + + const footer = screen.getByLabelText('Source status') + expect(footer.textContent).toContain('OMITTED') + expect(footer.textContent).toContain('[2]') + expect(footer.textContent).toContain('deselected by you') + }) + + it('does not show the source status footer when all sources are used', async () => { + global.fetch = makeMockFetch('Report.') as any + + render() + + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: /generate research report/i })) + }) + + await waitFor(() => expect(screen.queryByTestId('ai-markdown')).not.toBeNull()) + + expect(screen.queryByLabelText('Source status')).toBeNull() + }) + + // ── Error handling ─────────────────────────────────────────────────── + + it('shows an error alert when fetch fails', async () => { + global.fetch = vi.fn().mockRejectedValue(new Error('Network failure')) as any + + render() + + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: /generate research report/i })) + }) + + await waitFor(() => { + const alert = screen.queryByRole('alert') + expect(alert).not.toBeNull() + }) + expect(screen.getByRole('alert').textContent).toContain('Network failure') + }) + + it('shows an error alert when server returns non-ok status', async () => { + global.fetch = vi.fn().mockResolvedValue({ + ok: false, + status: 503, + headers: { get: () => 'application/json' }, + }) as any + + render() + + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: /generate research report/i })) + }) + + await waitFor(() => { + const alert = screen.queryByRole('alert') + expect(alert).not.toBeNull() + }) + expect(screen.getByRole('alert').textContent).toContain('503') + }) + + // ── State reset on query/results change ───────────────────────────── + + it('resets all state when query prop changes', async () => { + global.fetch = makeMockFetch('Old report.') as any + + const newResults = [makeResult('10', 'New result')] + const { rerender } = render() + + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: /generate research report/i })) + }) + await waitFor(() => expect(screen.queryByTestId('ai-markdown')).not.toBeNull()) + + rerender() + + // Report should be cleared + expect(screen.queryByTestId('ai-markdown')).toBeNull() + // New source should appear, selected + expect(screen.getByLabelText('Include source: New result')).not.toBeNull() + }) + + // ── REGENERATE button ───────────────────────────────────────────────── + + it('shows REGENERATE REPORT label after a report has been generated', async () => { + global.fetch = makeMockFetch('Report content.') as any + + render() + + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: /generate research report/i })) + }) + + await waitFor(() => { + const btn = screen.queryByRole('button', { name: /regenerate report/i }) + expect(btn).not.toBeNull() + }) + }) +}) diff --git a/src/components/search/ResearchWorkflowPanel.tsx b/src/components/search/ResearchWorkflowPanel.tsx new file mode 100644 index 0000000..5303063 --- /dev/null +++ b/src/components/search/ResearchWorkflowPanel.tsx @@ -0,0 +1,579 @@ +/** + * ResearchWorkflowPanel + * + * Lets the user: + * 1. Select which result sources to include in the report + * 2. Choose a report format (bullets / narrative / table / comparison) + * 3. Generate the AI research report via /ai/chat + * + * The generated report retains a per-source status map (used / omitted / failed) + * and exposes the omitted/failed lists to the parent if needed. + */ +import { useState, useEffect, useCallback, useRef } from 'react' +import { motion, AnimatePresence } from 'framer-motion' +import { + Sparkles, + CheckSquare, + Square, + ChevronDown, + ChevronUp, + AlertCircle, + List, + AlignLeft, + Table2, + GitCompare, +} from 'lucide-react' +import { AiMarkdown } from '../ai/AiMarkdown' +import type { SearchResult } from '../../hooks/useSearch' +import type { ReportFormat, ResearchReport, SourceStatus } from '../../types/index' +import { buildResearchPrompt } from '../../lib/aiChatService' + +// ─── Constants ────────────────────────────────────────────────────────────── + +const SERVER_URL = (import.meta as any).env?.VITE_SERVER_URL ?? ( + typeof window !== 'undefined' && window.location.origin.includes('vercel.app') + ? `${window.location.origin}/api` + : 'http://localhost:3001' +) + +const FORMAT_OPTIONS: { value: ReportFormat; label: string; description: string; icon: React.ComponentType<{ className?: string }> }[] = [ + { + value: 'bullets', + label: 'BULLET POINTS', + description: 'Concise key findings with citations', + icon: List, + }, + { + value: 'narrative', + label: 'NARRATIVE', + description: 'Flowing prose with inline citations', + icon: AlignLeft, + }, + { + value: 'table', + label: 'TABLE', + description: 'Markdown table: source | claim | detail', + icon: Table2, + }, + { + value: 'comparison', + label: 'COMPARISON', + description: 'Side-by-side contrast of sources', + icon: GitCompare, + }, +] + +// ─── Props ────────────────────────────────────────────────────────────────── + +interface Props { + results: SearchResult[] + query: string + /** Called when a citation [N] is clicked so the parent can scroll to the card. */ + onCitationClick?: (index: number) => void +} + +// ─── Component ────────────────────────────────────────────────────────────── + +export function ResearchWorkflowPanel({ results, query, onCitationClick }: Props) { + // ── source selection ─────────────────────────────────────────────────── + const [selectedIds, setSelectedIds] = useState>(() => new Set(results.map((r) => r.id))) + + // ── format ──────────────────────────────────────────────────────────── + const [format, setFormat] = useState('bullets') + + // ── report state ────────────────────────────────────────────────────── + const [report, setReport] = useState(null) + const [generating, setGenerating] = useState(false) + const [genError, setGenError] = useState(null) + + // ── UI state ────────────────────────────────────────────────────────── + const [sourcesExpanded, setSourcesExpanded] = useState(true) + + // Reset everything when query or results change + useEffect(() => { + setSelectedIds(new Set(results.map((r) => r.id))) + setReport(null) + setGenError(null) + setGenerating(false) + setSourcesExpanded(true) + }, [query, results]) + + // ── selection helpers ───────────────────────────────────────────────── + const toggleSource = useCallback((id: string) => { + setSelectedIds((prev) => { + const next = new Set(prev) + if (next.has(id)) { + next.delete(id) + } else { + next.add(id) + } + return next + }) + }, []) + + const selectAll = useCallback(() => { + setSelectedIds(new Set(results.map((r) => r.id))) + }, [results]) + + const deselectAll = useCallback(() => { + setSelectedIds(new Set()) + }, []) + + const allSelected = selectedIds.size === results.length + const noneSelected = selectedIds.size === 0 + + // ── report generation ───────────────────────────────────────────────── + const abortRef = useRef(null) + + const generate = useCallback(async () => { + if (generating || noneSelected) return + + // Cancel any previous in-flight request + abortRef.current?.abort() + const controller = new AbortController() + abortRef.current = controller + + setGenerating(true) + setGenError(null) + setReport(null) + + const selectedSources = results + .filter((r) => selectedIds.has(r.id)) + .map((r) => ({ + id: r.id, + title: r.title, + url: r.url, + description: r.description, + })) + + const allIds = results.map((r) => r.id) + const { prompt, omittedIds } = buildResearchPrompt(query, selectedSources, format, allIds) + + // Build source status list for the report metadata + const buildSourceStatuses = (failedIds: string[]): SourceStatus[] => { + const failedSet = new Set(failedIds) + return results.map((r) => ({ + id: r.id, + title: r.title, + url: r.url, + status: omittedIds.includes(r.id) + ? 'omitted' + : failedSet.has(r.id) + ? 'failed' + : 'used', + })) + } + + let content = '' + + try { + const res = await fetch(`${SERVER_URL}/ai/chat`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'text/event-stream', + }, + body: JSON.stringify({ + messages: [{ role: 'user', content: prompt }], + }), + signal: controller.signal, + }) + + if (!res.ok) throw new Error(`Server error ${res.status}`) + + const isSSE = res.headers.get('content-type')?.includes('text/event-stream') + if (isSSE && res.body) { + const reader = res.body.getReader() + const decoder = new TextDecoder('utf-8') + let buffer = '' + + while (true) { + const { value, done } = await reader.read() + if (done) break + buffer += decoder.decode(value, { stream: true }) + + let blank: number + while ((blank = buffer.indexOf('\n\n')) !== -1) { + const raw = buffer.slice(0, blank) + buffer = buffer.slice(blank + 2) + let event = 'message' + let data = '' + for (const line of raw.split('\n')) { + if (line.startsWith('event:')) event = line.slice(6).trim() + else if (line.startsWith('data:')) data += line.slice(5).trim() + } + if (!data) continue + if (event === 'delta') { + try { + const { content: chunk } = JSON.parse(data) as { content?: string } + if (chunk) { + content += chunk + // Update report content progressively so the user sees streaming output + setReport({ + format, + sources: buildSourceStatuses([]), + content, + omitted: omittedIds, + failed: [], + }) + } + } catch { /* skip malformed */ } + } else if (event === 'done') { + break + } else if (event === 'error') { + try { + const { error } = JSON.parse(data) as { error?: string } + throw new Error(error || 'stream error') + } catch (e) { + throw e instanceof Error ? e : new Error('stream error') + } + } + } + } + } else { + const data = await res.json() + content = data.content ?? 'No report returned.' + } + + setReport({ + format, + sources: buildSourceStatuses([]), + content, + omitted: omittedIds, + failed: [], + }) + // Collapse source list once report is shown + setSourcesExpanded(false) + } catch (err: any) { + if (err.name === 'AbortError') return + setGenError(err.message || 'Failed to generate report.') + } finally { + if (abortRef.current === controller) { + abortRef.current = null + } + setGenerating(false) + } + }, [generating, noneSelected, results, selectedIds, format, query]) + + // Clean up abort controller on unmount + useEffect(() => { + return () => { abortRef.current?.abort() } + }, []) + + // ── render ──────────────────────────────────────────────────────────── + return ( +
+ {/* ── Source Selection ── */} +
+ {/* Header row */} + + · + +
+ )} + {sourcesExpanded + ? + : + } +
+ + + {/* Source list */} + + {sourcesExpanded && ( + +
+ {results.map((result, idx) => { + const checked = selectedIds.has(result.id) + return ( + + ) + })} +
+
+ )} +
+ + + {/* ── Format Selector ── */} +
+

REPORT FORMAT

+
+ {FORMAT_OPTIONS.map(({ value, label, description, icon: Icon }) => { + const selected = format === value + return ( + + ) + })} +
+
+ + {/* ── Generate Button ── */} + + + {noneSelected && !generating && ( +

+ Select at least one source to generate a report. +

+ )} + + {/* ── Error ── */} + + {genError && ( + + +

⚠ {genError}

+
+ )} +
+ + {/* ── Report Output ── */} + + {report && ( + + {/* Report header */} +
+ + + RESEARCH REPORT · GROQ · {report.format.toUpperCase()} + + {generating && ( + + {[0, 1, 2].map((j) => ( + + ))} + + )} +
+ + {/* Report body */} +
+
+ + {generating && } +
+
+ + {/* Source status footer */} + {!generating && (report.omitted.length > 0 || report.failed.length > 0) && ( +
+ {report.omitted.length > 0 && ( +

+ OMITTED:{' '} + {report.omitted + .map((id) => { + const idx = results.findIndex((r) => r.id === id) + return idx !== -1 ? `[${idx + 1}]` : id + }) + .join(', ')} + {' '}(deselected by you) +

+ )} + {report.failed.length > 0 && ( +

+ FAILED:{' '} + {report.failed + .map((id) => { + const idx = results.findIndex((r) => r.id === id) + return idx !== -1 ? `[${idx + 1}]` : id + }) + .join(', ')} + {' '}(could not be processed) +

+ )} +
+ )} +
+ )} +
+ + ) +} diff --git a/src/components/search/SearchResults.test.tsx b/src/components/search/SearchResults.test.tsx index 1d00847..08d72fd 100644 --- a/src/components/search/SearchResults.test.tsx +++ b/src/components/search/SearchResults.test.tsx @@ -1,13 +1,13 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' -import { render, screen, act } from '@testing-library/react' +import { render, screen, act, fireEvent } from '@testing-library/react' import { SearchResults } from './SearchResults' import type { SearchResult } from '../../hooks/useSearch' -// Mock framer-motion to avoid animation issues in tests +// ── Framer-motion stub ───────────────────────────────────────────────────── vi.mock('framer-motion', () => ({ motion: { div: ({ children, ...props }: any) =>
{children}
, - a: ({ children, ...props }: any) => {children}, + a: ({ children, ...props }: any) => {children}, }, AnimatePresence: ({ children }: any) => <>{children}, })) @@ -15,17 +15,25 @@ vi.mock('framer-motion', () => ({ function filterMotionProps(props: Record) { const safe: Record = {} for (const [k, v] of Object.entries(props)) { - if (k === 'initial' || k === 'animate' || k === 'exit' || k === 'transition' || k === 'whileHover') continue + if (['initial', 'animate', 'exit', 'transition', 'whileHover', 'whileTap'].includes(k)) continue safe[k] = v } return safe } -// Mock sonner +// ── ResearchWorkflowPanel stub ───────────────────────────────────────────── +vi.mock('./ResearchWorkflowPanel', () => ({ + ResearchWorkflowPanel: ({ results, query }: any) => ( +
+ ), +})) + +// ── Sonner stub ──────────────────────────────────────────────────────────── vi.mock('sonner', () => ({ toast: { info: vi.fn(), success: vi.fn(), error: vi.fn() }, })) +// ── Helpers ──────────────────────────────────────────────────────────────── const makeResult = (id: string, title: string): SearchResult => ({ id, title, @@ -45,7 +53,7 @@ const mockResults2: SearchResult[] = [ makeResult('4', 'Stellar DEX'), ] -// Mock fetch for summarize +// ── Fetch mock ───────────────────────────────────────────────────────────── const mockFetch = vi.fn().mockResolvedValue({ ok: true, headers: { get: () => 'application/json' }, @@ -53,6 +61,8 @@ const mockFetch = vi.fn().mockResolvedValue({ }) global.fetch = mockFetch as any +// ── Tests ────────────────────────────────────────────────────────────────── + describe('SearchResults — summary state reset (issue #95)', () => { beforeEach(() => { vi.clearAllMocks() @@ -64,27 +74,21 @@ describe('SearchResults — summary state reset (issue #95)', () => { , ) - // Click summarize button - const summarizeBtn = screen.getByText('SUMMARIZE') - await act(async () => { - summarizeBtn.click() - }) - - // After summarize completes, summary should appear + // Click summarize to open summarize panel and trigger the AI call await act(async () => { - // wait for fetch to resolve + fireEvent.click(screen.getByRole('button', { name: /quick ai summary/i })) }) + await act(async () => {}) - // Verify summary or regenerate button is showing - const regenerateBtn = screen.queryByText('REGENERATE') - expect(regenerateBtn).not.toBeNull() + // REGENERATE label should appear on the summarize button + expect(screen.queryByRole('button', { name: /regenerate/i })).not.toBeNull() - // Now re-render with a new query + // New query rerender() - // Summary should be cleared — SUMMARIZE button should be back (not REGENERATE) - expect(screen.queryByText('REGENERATE')).toBeNull() - expect(screen.getByText('SUMMARIZE')).toBeInTheDocument() + // Panel should reset + expect(screen.queryByRole('button', { name: /regenerate/i })).toBeNull() + expect(screen.getByRole('button', { name: /quick ai summary/i })).not.toBeNull() }) it('clears summary when results prop changes', async () => { @@ -92,51 +96,36 @@ describe('SearchResults — summary state reset (issue #95)', () => { , ) - // Click summarize - const summarizeBtn = screen.getByText('SUMMARIZE') await act(async () => { - summarizeBtn.click() + fireEvent.click(screen.getByRole('button', { name: /quick ai summary/i })) }) - await act(async () => {}) - // Verify summary state is set - expect(screen.queryByText('REGENERATE')).not.toBeNull() + expect(screen.queryByRole('button', { name: /regenerate/i })).not.toBeNull() - // Re-render with different results (same query) rerender() - // Summary should be cleared - expect(screen.queryByText('REGENERATE')).toBeNull() - expect(screen.getByText('SUMMARIZE')).toBeInTheDocument() + expect(screen.queryByRole('button', { name: /regenerate/i })).toBeNull() }) it('clears summary error when query changes', async () => { - // Make fetch fail global.fetch = vi.fn().mockRejectedValue(new Error('Network error')) const { rerender } = render( , ) - // Click summarize — will fail - const summarizeBtn = screen.getByText('SUMMARIZE') await act(async () => { - summarizeBtn.click() + fireEvent.click(screen.getByRole('button', { name: /quick ai summary/i })) }) - await act(async () => {}) - // Error should appear - expect(screen.getByText(/Network error/)).toBeInTheDocument() + expect(screen.queryByText(/Network error/)).not.toBeNull() - // Restore fetch and re-render with new query global.fetch = mockFetch as any rerender() - // Error should be cleared expect(screen.queryByText(/Network error/)).toBeNull() - expect(screen.getByText('SUMMARIZE')).toBeInTheDocument() }) it('renders results correctly after query change', () => { @@ -144,13 +133,13 @@ describe('SearchResults — summary state reset (issue #95)', () => { , ) - expect(screen.getByText('Stellar blockchain')).toBeInTheDocument() - expect(screen.getByText('x402 protocol')).toBeInTheDocument() + expect(screen.queryByText('Stellar blockchain')).not.toBeNull() + expect(screen.queryByText('x402 protocol')).not.toBeNull() rerender() - expect(screen.getByText('Soroban smart contracts')).toBeInTheDocument() - expect(screen.getByText('Stellar DEX')).toBeInTheDocument() + expect(screen.queryByText('Soroban smart contracts')).not.toBeNull() + expect(screen.queryByText('Stellar DEX')).not.toBeNull() expect(screen.queryByText('Stellar blockchain')).toBeNull() }) @@ -159,21 +148,108 @@ describe('SearchResults — summary state reset (issue #95)', () => { , ) - // Should show skeleton loading animation const skeletons = document.querySelectorAll('.animate-pulse') expect(skeletons.length).toBeGreaterThan(0) - // After loading finishes with results rerender() - expect(screen.getByText('Stellar blockchain')).toBeInTheDocument() + expect(screen.queryByText('Stellar blockchain')).not.toBeNull() }) it('returns null when results are empty and not loading', () => { const { container } = render( , ) + expect(screen.queryByRole('button', { name: /quick ai summary/i })).toBeNull() + expect(container.firstChild).toBeNull() + }) +}) + +describe('SearchResults — Research mode', () => { + beforeEach(() => { + vi.clearAllMocks() + global.fetch = mockFetch as any + }) + + it('renders the RESEARCH button alongside SUMMARIZE', () => { + render() + + expect(screen.getByRole('button', { name: /quick ai summary/i })).not.toBeNull() + expect(screen.getByRole('button', { name: /research report/i })).not.toBeNull() + }) + + it('shows ResearchWorkflowPanel when RESEARCH button is clicked', async () => { + render() + + expect(screen.queryByTestId('research-workflow-panel')).toBeNull() + + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: /research report/i })) + }) + + expect(screen.queryByTestId('research-workflow-panel')).not.toBeNull() + }) + + it('hides ResearchWorkflowPanel when RESEARCH is clicked again (toggle)', async () => { + render() + + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: /research report/i })) + }) + expect(screen.queryByTestId('research-workflow-panel')).not.toBeNull() + + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: /research report/i })) + }) + expect(screen.queryByTestId('research-workflow-panel')).toBeNull() + }) + + it('passes correct results and query to ResearchWorkflowPanel', async () => { + render() + + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: /research report/i })) + }) + + const panel = screen.getByTestId('research-workflow-panel') + expect(panel.getAttribute('data-query')).toBe('test query') + expect(panel.getAttribute('data-count')).toBe(String(mockResults.length)) + }) + + it('hides ResearchWorkflowPanel and resets when query changes', async () => { + const { rerender } = render( + , + ) + + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: /research report/i })) + }) + expect(screen.queryByTestId('research-workflow-panel')).not.toBeNull() + + rerender() + + expect(screen.queryByTestId('research-workflow-panel')).toBeNull() + }) +}) + +describe('SearchResults — result cards with citation IDs', () => { + it('renders each result card with an id of result-card-{n}', () => { + render() + + const card1 = document.getElementById('result-card-1') + const card2 = document.getElementById('result-card-2') + + expect(card1).not.toBeNull() + expect(card2).not.toBeNull() + expect(card1?.tagName.toLowerCase()).toBe('a') + }) + + it('card IDs are 1-based and match result order', () => { + render() + + const card1 = document.getElementById('result-card-1') + const card2 = document.getElementById('result-card-2') - // Should not render any result cards - expect(screen.queryByText('SUMMARIZE')).toBeNull() + expect(card1?.textContent).toContain('Stellar blockchain') + expect(card2?.textContent).toContain('x402 protocol') }) }) diff --git a/src/components/search/SearchResults.tsx b/src/components/search/SearchResults.tsx index f55d135..360805c 100644 --- a/src/components/search/SearchResults.tsx +++ b/src/components/search/SearchResults.tsx @@ -1,7 +1,8 @@ -import { useState } from 'react' +import { useState, useEffect, useRef } from 'react' import { motion, AnimatePresence } from 'framer-motion' -import { ExternalLink, Star, Clock, Sparkles, Search } from 'lucide-react' +import { ExternalLink, Star, Clock, Sparkles, Search, FlaskConical } from 'lucide-react' import type { SearchResult } from '../../hooks/useSearch' +import { ResearchWorkflowPanel } from './ResearchWorkflowPanel' interface Props { results: SearchResult[] @@ -15,11 +16,33 @@ const SERVER_URL = (import.meta as any).env?.VITE_SERVER_URL ?? ( : 'http://localhost:3001' ) +/** Tracks which AI panel mode is currently visible */ +type AiMode = 'none' | 'summarize' | 'research' + export function SearchResults({ results, query, isLoading }: Props) { + // Legacy quick-summarize state const [summary, setSummary] = useState('') const [summaryError, setSummaryError] = useState(null) const [summarizing, setSummarizing] = useState(false) + // Which AI panel is visible + const [aiMode, setAiMode] = useState('none') + + // Reset all AI state when query or results change (issue #95) + useEffect(() => { + setSummary('') + setSummaryError(null) + setSummarizing(false) + setAiMode('none') + }, [query, results]) + + // Abort controller for legacy summarize + const summarizeAbortRef = useRef(null) + useEffect(() => { + return () => { summarizeAbortRef.current?.abort() } + }, []) + + // ── Loading skeleton ──────────────────────────────────────────────── if (isLoading) { return (
@@ -44,8 +67,13 @@ export function SearchResults({ results, query, isLoading }: Props) { if (!results.length) return null + // ── Legacy summarize (quick, first-5) ─────────────────────────────── const summarize = async () => { if (summarizing) return + summarizeAbortRef.current?.abort() + const controller = new AbortController() + summarizeAbortRef.current = controller + setSummarizing(true) setSummaryError(null) setSummary('') @@ -69,6 +97,7 @@ export function SearchResults({ results, query, isLoading }: Props) { body: JSON.stringify({ messages: [{ role: 'user', content: prompt }], }), + signal: controller.signal, }) if (!res.ok) throw new Error(`Server error ${res.status}`) @@ -114,28 +143,89 @@ export function SearchResults({ results, query, isLoading }: Props) { setSummary(data.content ?? 'No summary returned.') } } catch (err: any) { + if (err.name === 'AbortError') return setSummaryError(err.message || 'Failed to generate summary.') } finally { setSummarizing(false) } } + // ── Panel toggles ──────────────────────────────────────────────────── + const openSummarize = () => { + setAiMode((prev) => { + if (prev !== 'summarize') return 'summarize' + return 'none' + }) + if (aiMode === 'none' || aiMode === 'research') { + // Auto-trigger summarize when switching into summarize mode + } + } + + const handleSummarizeClick = () => { + if (aiMode === 'summarize') { + // Already open — allow re-generation + summarize() + } else { + setAiMode('summarize') + // Kick off summarize after opening panel + setTimeout(summarize, 0) + } + } + + const handleResearchClick = () => { + setAiMode((prev) => (prev === 'research' ? 'none' : 'research')) + } + + // Scroll to a result card by 1-based citation index + const handleCitationClick = (index: number) => { + if (typeof document === 'undefined') return + const el = document.getElementById(`result-card-${index}`) + if (el) { + el.scrollIntoView({ behavior: 'smooth', block: 'center' }); + (el as HTMLElement).focus({ preventScroll: true }) + } + } + return ( + {/* ── Toolbar ── */}
-

+

{results.length} RESULTS · SERPER.DEV · PAID VIA x402

-
+
+ {/* Quick-summarize button */} + + {/* Research workflow button */} + +
LIVE @@ -143,8 +233,9 @@ export function SearchResults({ results, query, isLoading }: Props) {
+ {/* ── Quick-summary panel ── */} - {(summarizing || summary || summaryError) && ( + {aiMode === 'summarize' && (summarizing || summary || summaryError) && ( + {/* ── Research workflow panel ── */} + + {aiMode === 'research' && ( + + + + )} + + + {/* ── Result cards ── */} {results.map((r, i) => ( { }) }) }) + +import { buildResearchPrompt, ResearchSource } from './aiChatService' +import type { ReportFormat } from '../types/index' + +describe('buildResearchPrompt', () => { + const sources: ResearchSource[] = [ + { id: '1', title: 'Stellar Overview', url: 'https://stellar.org', description: 'Stellar is a payment network.' }, + { id: '2', title: 'x402 Protocol', url: 'https://x402.org', description: 'x402 is a payment protocol.' }, + { id: '3', title: 'Serper.dev Docs', url: 'https://serper.dev/docs', description: 'Serper provides Google search API.' }, + ] + const allIds = ['1', '2', '3', '4'] // '4' was not selected + + const FORMATS: ReportFormat[] = ['bullets', 'narrative', 'table', 'comparison'] + + describe('omittedIds computation', () => { + it('returns ids present in allSourceIds but absent from sources', () => { + const { omittedIds } = buildResearchPrompt('test query', sources, 'bullets', allIds) + expect(omittedIds).toEqual(['4']) + }) + + it('returns empty array when all ids are selected', () => { + const { omittedIds } = buildResearchPrompt('test', sources, 'bullets', ['1', '2', '3']) + expect(omittedIds).toHaveLength(0) + }) + + it('returns all ids when no sources are selected', () => { + const { omittedIds } = buildResearchPrompt('test', [], 'bullets', allIds) + expect(omittedIds).toEqual(allIds) + }) + + it('handles duplicate allSourceIds gracefully', () => { + const { omittedIds } = buildResearchPrompt('test', [sources[0]], 'bullets', ['1', '1', '2']) + expect(omittedIds).toContain('2') + expect(omittedIds).not.toContain('1') + }) + }) + + describe('prompt content', () => { + it('includes the query in the prompt', () => { + const { prompt } = buildResearchPrompt('stellar x402', sources, 'bullets', allIds) + expect(prompt).toContain('stellar x402') + }) + + it('includes all source titles, urls, and descriptions', () => { + const { prompt } = buildResearchPrompt('stellar', sources, 'bullets', allIds) + for (const s of sources) { + expect(prompt).toContain(s.title) + expect(prompt).toContain(s.url) + expect(prompt).toContain(s.description) + } + }) + + it('uses 1-based citation numbers [1], [2], [3] for selected sources', () => { + const { prompt } = buildResearchPrompt('query', sources, 'bullets', allIds) + expect(prompt).toContain('[1]') + expect(prompt).toContain('[2]') + expect(prompt).toContain('[3]') + }) + + it('includes a "Sources Used" section at the end', () => { + const { prompt } = buildResearchPrompt('query', sources, 'bullets', allIds) + expect(prompt).toContain('Sources Used:') + expect(prompt).toContain('Stellar Overview — https://stellar.org') + }) + + FORMATS.forEach((fmt) => { + it(`includes format-specific instructions for "${fmt}"`, () => { + const { prompt } = buildResearchPrompt('query', sources, fmt, allIds) + const instructions: Record = { + bullets: 'bullet-point', + narrative: 'prose', + table: 'Markdown table', + comparison: 'Compare and contrast', + } + expect(prompt.toLowerCase()).toContain(instructions[fmt].toLowerCase()) + }) + }) + + it('handles zero selected sources without throwing', () => { + expect(() => buildResearchPrompt('q', [], 'bullets', ['1', '2'])).not.toThrow() + }) + + it('prompt is stable with same inputs', () => { + const a = buildResearchPrompt('query', sources, 'narrative', allIds) + const b = buildResearchPrompt('query', sources, 'narrative', allIds) + expect(a.prompt).toBe(b.prompt) + expect(a.omittedIds).toEqual(b.omittedIds) + }) + }) + + describe('source ordering', () => { + it('numbers sources in the order they are provided', () => { + const reversed = [...sources].reverse() + const { prompt } = buildResearchPrompt('q', reversed, 'bullets', allIds) + // [1] should map to the first source in the reversed array (Serper.dev) + const firstRef = prompt.indexOf('[1]') + const serperIdx = prompt.indexOf('Serper.dev Docs') + const stellarIdx = prompt.indexOf('Stellar Overview') + // [1] appears before the Stellar Overview entry since Serper is first + expect(serperIdx).toBeLessThan(stellarIdx) + expect(firstRef).toBeGreaterThan(0) + }) + }) +}) diff --git a/src/lib/aiChatService.ts b/src/lib/aiChatService.ts index 97b1d3a..ee1e269 100644 --- a/src/lib/aiChatService.ts +++ b/src/lib/aiChatService.ts @@ -2,6 +2,27 @@ * Shared runtime-neutral AI chat service * Used across Express server, Vercel Serverless API, browser UI, and MCP server. */ +import type { ReportFormat } from '../types/index' + +/** A minimal source descriptor used when building research prompts. */ +export interface ResearchSource { + id: string + title: string + url: string + description: string +} + +/** Result of buildResearchPrompt — the ready-to-send prompt plus book-keeping. */ +export interface ResearchPromptResult { + /** The full prompt string to send to the AI. */ + prompt: string + /** + * IDs that were present in allSourceIds but NOT in selectedIds. + * These are the sources the user deliberately excluded; they should be + * recorded in the generated ResearchReport.omitted list. + */ + omittedIds: string[] +} export interface ChatMessage { role: 'system' | 'user' | 'assistant' @@ -151,3 +172,79 @@ export function formatAiError(err: any): { message: string } { message: `Groq AI error: ${rawMsg}`, } } + +// ─── Format instruction strings ──────────────────────────────────────────── + +const FORMAT_INSTRUCTIONS: Record = { + bullets: [ + 'Write a concise bullet-point summary (3–7 bullets).', + 'Each bullet must cite its source(s) using [N] notation.', + 'Group related findings under one bullet where possible.', + ].join(' '), + narrative: [ + 'Write a flowing prose summary (2–4 paragraphs).', + 'Cite sources inline with [N] notation.', + 'Do not use headers or bullet lists — use natural paragraph flow.', + ].join(' '), + table: [ + 'Produce a Markdown table with columns: Source [N] | Key Claim | Detail.', + 'One row per source. Do not include any text outside the table.', + ].join(' '), + comparison: [ + 'Compare and contrast the sources\' positions on the topic.', + 'Organise by theme: agreements first, then disagreements.', + 'Cite sources inline with [N] notation.', + 'Use short paragraphs rather than bullet points.', + ].join(' '), +} + +/** + * Builds a format-specific research prompt and computes the list of omitted + * source IDs (those present in allSourceIds but absent from selectedIds). + * + * @param query - The original search query string. + * @param sources - The full list of available sources (only those whose + * id appears in selectedIds will be included in the + * prompt context). + * @param format - The desired output format. + * @param allSourceIds - IDs of every source in the result set (used to + * determine which sources were omitted). + */ +export function buildResearchPrompt( + query: string, + sources: ResearchSource[], + format: ReportFormat, + allSourceIds: string[], +): ResearchPromptResult { + const selectedSet = new Set(sources.map((s) => s.id)) + const omittedIds = allSourceIds.filter((id) => !selectedSet.has(id)) + + const sourceBlock = sources + .map((s, idx) => + `[${idx + 1}] ${s.title}\n URL: ${s.url}\n Excerpt: ${s.description}`, + ) + .join('\n\n') + + const formatInstruction = FORMAT_INSTRUCTIONS[format] + + const prompt = [ + `You are a research assistant. The user searched for: "${query}".`, + ``, + `Below are ${sources.length} selected source(s). ` + + `Cite them using [1], [2], … [${sources.length}] notation.`, + ``, + sourceBlock, + ``, + `---`, + ``, + `Instructions: ${formatInstruction}`, + ``, + `At the very end of your response, include a "Sources Used" section that ` + + `lists each source number, its title, and its URL, like:`, + ``, + `Sources Used:`, + ...sources.map((s, idx) => `[${idx + 1}] ${s.title} — ${s.url}`), + ].join('\n') + + return { prompt, omittedIds } +} diff --git a/src/types/index.ts b/src/types/index.ts index a0d7b24..62f712d 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -203,6 +203,47 @@ export interface BatchJsonlDoneEvent extends BatchJsonlEvent { completedAt: string } +// ─── Research Workflow Types ─────────────────────────────────────────────── + +/** + * Output format the AI should produce when generating a research report. + * - bullets: concise bullet-point summary with citations + * - narrative: flowing prose with inline citations + * - table: markdown table comparing sources by key claim + * - comparison: side-by-side contrast of sources' positions + */ +export type ReportFormat = 'bullets' | 'narrative' | 'table' | 'comparison' + +/** Config that the user supplies before invoking the AI research step. */ +export interface ResearchReportConfig { + /** IDs of the SearchResult items the user wants included in the report. */ + selectedSourceIds: string[] + /** How the AI should structure its output. */ + format: ReportFormat +} + +/** Status of a single source after report generation. */ +export interface SourceStatus { + id: string + title: string + url: string + /** 'used' | 'omitted' (deselected by user) | 'failed' (AI could not process it) */ + status: 'used' | 'omitted' | 'failed' +} + +/** Result of a research report generation. */ +export interface ResearchReport { + format: ReportFormat + /** Per-source status map so the UI can show which sources were used/omitted/failed. */ + sources: SourceStatus[] + /** AI-generated report content in the requested format. */ + content: string + /** IDs of sources the user chose to omit. */ + omitted: string[] + /** IDs of sources that the AI reported it could not process. */ + failed: string[] +} + // ─── Job Types ───────────────────────────────────────────────────────────── export interface SearchJob { id: string