diff --git a/apps/website/app/(ai)/ai/ExtractionApp.tsx b/apps/website/app/(ai)/ai/ExtractionApp.tsx new file mode 100644 index 000000000..b4ca6ba6a --- /dev/null +++ b/apps/website/app/(ai)/ai/ExtractionApp.tsx @@ -0,0 +1,111 @@ +"use client"; + +import { useCallback, useState } from "react"; +import { NODE_TYPES } from "~/types/extraction"; +import type { ExtractionResult, NodeType } from "~/types/extraction"; +import { MainContent } from "./components/MainContent"; +import { Sidebar } from "./components/Sidebar"; +import { useExtraction } from "./hooks/useExtraction"; +import { useModels } from "./hooks/useModels"; +import { usePdfParser } from "./hooks/usePdfParser"; + +type AppState = "idle" | "processing" | "results" | "error"; + +// eslint-disable-next-line @typescript-eslint/naming-convention +export const ExtractionApp = () => { + const [file, setFile] = useState(null); + const [selectedModel, setSelectedModel] = useState(""); + const [researchQuestion, setResearchQuestion] = useState(""); + const [selectedTypes, setSelectedTypes] = useState>( + () => new Set(NODE_TYPES), + ); + + const [appState, setAppState] = useState("idle"); + const [result, setResult] = useState(null); + const [error, setError] = useState(null); + + const pdfParser = usePdfParser(); + const extraction = useExtraction(); + const modelsHook = useModels(); + + const modelValue = selectedModel || (modelsHook.models[0]?.id ?? ""); + + const toggleType = useCallback((type: NodeType) => { + setSelectedTypes((prev) => { + const next = new Set(prev); + if (next.has(type)) { + next.delete(type); + } else { + next.add(type); + } + return next; + }); + }, []); + + const handleExtract = useCallback(async () => { + if (!file || selectedTypes.size === 0 || !modelValue) { + return; + } + + setAppState("processing"); + setError(null); + + const parsed = await pdfParser.parse(file); + if (!parsed) { + setError(pdfParser.error ?? "Failed to parse PDF"); + setAppState("error"); + return; + } + + const extractionResult = await extraction.extract({ + paperText: parsed.text, + nodeTypes: Array.from(selectedTypes), + model: modelValue, + researchQuestion: researchQuestion.trim() || undefined, + }); + + if (!extractionResult) { + setError(extraction.error ?? "Extraction failed"); + setAppState("error"); + return; + } + + setResult(extractionResult); + setAppState("results"); + }, [ + extraction, + file, + modelValue, + pdfParser, + researchQuestion, + selectedTypes, + ]); + + return ( +
+ + +
+ ); +}; diff --git a/apps/website/app/(ai)/ai/components/MainContent.tsx b/apps/website/app/(ai)/ai/components/MainContent.tsx new file mode 100644 index 000000000..026991c9a --- /dev/null +++ b/apps/website/app/(ai)/ai/components/MainContent.tsx @@ -0,0 +1,143 @@ +"use client"; + +import type { ExtractionResult } from "~/types/extraction"; +import { ResultsPanel } from "./ResultsPanel"; + +type MainContentProps = { + state: "idle" | "processing" | "results" | "error"; + result: ExtractionResult | null; + error: string | null; + fileName?: string; + modelName?: string; +}; + +// eslint-disable-next-line @typescript-eslint/naming-convention +export const MainContent = ({ + state, + result, + error, + fileName, + modelName, +}: MainContentProps) => { + const panelClassName = + "flex min-h-[420px] flex-1 overflow-hidden rounded-[24px] border border-slate-200/85 bg-white shadow-[0_24px_48px_-36px_rgba(15,23,42,0.55)]"; + + if (state === "processing") { + return ( +
+
+
+
+
+
+
+
+
+ +

+ Extracting discourse nodes +

+ {fileName && modelName && ( +

+ {fileName} · {modelName} +

+ )} +

+ This can take up to a minute for longer papers. +

+
+
+
+ ); + } + + if (state === "error") { + return ( +
+
+
+
+ + + +
+ +
+

+ Extraction failed +

+

+ {error} +

+
+ +

+ Check your sidebar settings and try again. +

+
+
+
+ ); + } + + if (state === "results" && result) { + return ( +
+ +
+ ); + } + + return ( +
+
+
+
+ + + +
+ +

+ No results yet +

+

+ Upload a paper, choose what to extract, then run extraction. +

+ +
+ + 1. Upload PDF + + + 2. Set model + + + 3. Run extraction + +
+
+
+
+ ); +}; diff --git a/apps/website/app/(ai)/ai/components/NodeCard.tsx b/apps/website/app/(ai)/ai/components/NodeCard.tsx new file mode 100644 index 000000000..9f75edbb4 --- /dev/null +++ b/apps/website/app/(ai)/ai/components/NodeCard.tsx @@ -0,0 +1,145 @@ +"use client"; + +import { useState } from "react"; +import { NODE_TYPE_COLORS, NODE_TYPE_LABELS } from "~/types/extraction"; +import type { ExtractedNode } from "~/types/extraction"; + +type NodeCardProps = { + node: ExtractedNode; + selected: boolean; + onToggle: () => void; +}; + +// eslint-disable-next-line @typescript-eslint/naming-convention +export const NodeCard = ({ node, selected, onToggle }: NodeCardProps) => { + const [expanded, setExpanded] = useState(false); + const color = NODE_TYPE_COLORS[node.type]; + + return ( +
+ + +
+ + +
+
+ + {NODE_TYPE_LABELS[node.type]} + + + {node.confidence !== undefined && ( + + {Math.round(node.confidence * 100)}% + + )} + + {node.pageNumber !== undefined && ( + + p.{node.pageNumber} + + )} +
+ +

+ {node.content} +

+ + {(node.sourceQuote || node.section || node.reasoning) && ( + <> + + + {expanded && ( +
+ {node.sourceQuote && ( +

+ “{node.sourceQuote}” +

+ )} + + {node.section && ( +

+ + Section: + {" "} + {node.section} +

+ )} + + {node.reasoning && ( +

+ {node.reasoning} +

+ )} +
+ )} + + )} +
+
+
+ ); +}; diff --git a/apps/website/app/(ai)/ai/components/ProcessingStep.tsx b/apps/website/app/(ai)/ai/components/ProcessingStep.tsx new file mode 100644 index 000000000..eda54a20c --- /dev/null +++ b/apps/website/app/(ai)/ai/components/ProcessingStep.tsx @@ -0,0 +1,19 @@ +"use client"; + +// eslint-disable-next-line @typescript-eslint/naming-convention +export const ProcessingStep = () => { + return ( +
+ {/* Spinner */} +
+
+

+ Extracting discourse nodes... +

+

+ This may take up to a minute depending on paper length. +

+
+
+ ); +}; diff --git a/apps/website/app/(ai)/ai/components/ResultsPanel.tsx b/apps/website/app/(ai)/ai/components/ResultsPanel.tsx new file mode 100644 index 000000000..c9c9d8006 --- /dev/null +++ b/apps/website/app/(ai)/ai/components/ResultsPanel.tsx @@ -0,0 +1,217 @@ +"use client"; + +import { useCallback, useMemo, useState } from "react"; +import { NODE_TYPES, NODE_TYPE_LABELS } from "~/types/extraction"; +import type { ExtractionResult, NodeType } from "~/types/extraction"; +import { formatNodesForClipboard } from "~/utils/ai/formatClipboard"; +import { NodeCard } from "./NodeCard"; +import { TypeTabs } from "./TypeTabs"; + +type ResultsPanelProps = { + result: ExtractionResult; +}; + +type IndexedNode = { + node: ExtractionResult["nodes"][number]; + index: number; +}; + +// eslint-disable-next-line @typescript-eslint/naming-convention +export const ResultsPanel = ({ result }: ResultsPanelProps) => { + const [selectedIndices, setSelectedIndices] = useState>( + () => new Set(result.nodes.keys()), + ); + const [copied, setCopied] = useState(false); + const [activeTab, setActiveTab] = useState<"all" | NodeType>("all"); + + const allSelected = selectedIndices.size === result.nodes.length; + + const toggleAll = useCallback(() => { + if (allSelected) { + setSelectedIndices(new Set()); + return; + } + + setSelectedIndices(new Set(result.nodes.keys())); + }, [allSelected, result.nodes]); + + const toggleNode = useCallback((index: number) => { + setSelectedIndices((prev) => { + const next = new Set(prev); + if (next.has(index)) { + next.delete(index); + } else { + next.add(index); + } + return next; + }); + }, []); + + const handleCopy = useCallback(() => { + const selected = result.nodes.filter( + (node, index) => Boolean(node) && selectedIndices.has(index), + ); + const text = formatNodesForClipboard(selected); + + void navigator.clipboard.writeText(text).then(() => { + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }); + }, [result.nodes, selectedIndices]); + + const groupedNodes = useMemo(() => { + const groups: { type: NodeType; nodes: IndexedNode[] }[] = []; + + for (const type of NODE_TYPES) { + const matching = result.nodes + .map((node, index) => ({ node, index })) + .filter(({ node }) => node.type === type); + + if (matching.length > 0) { + groups.push({ type, nodes: matching }); + } + } + + return groups; + }, [result.nodes]); + + const filteredNodes = useMemo(() => { + if (activeTab === "all") { + return null; + } + + return result.nodes + .map((node, index) => ({ node, index })) + .filter(({ node }) => node.type === activeTab); + }, [activeTab, result.nodes]); + + return ( +
+ {(result.paperTitle || result.paperAuthors?.length) && ( +
+
+ {result.paperTitle && ( +

+ {result.paperTitle} +

+ )} + {result.paperAuthors?.length ? ( +

+ {result.paperAuthors.join(", ")} +

+ ) : null} +
+ )} + + + +
+ {activeTab === "all" ? ( +
+ {groupedNodes.map(({ type, nodes }) => ( +
+
+

+ {NODE_TYPE_LABELS[type]}s +

+ + {nodes.length} + + +
+ +
+ {nodes.map(({ node, index }) => ( + toggleNode(index)} + /> + ))} +
+
+ ))} +
+ ) : ( +
+ {filteredNodes?.map(({ node, index }) => ( + toggleNode(index)} + /> + ))} +
+ )} +
+ +
+
+ + + {selectedIndices.size} of {result.nodes.length} selected + +
+ + +
+
+ ); +}; diff --git a/apps/website/app/(ai)/ai/components/ResultsStep.tsx b/apps/website/app/(ai)/ai/components/ResultsStep.tsx new file mode 100644 index 000000000..0b74e8573 --- /dev/null +++ b/apps/website/app/(ai)/ai/components/ResultsStep.tsx @@ -0,0 +1,133 @@ +"use client"; + +import { useState, useMemo, useCallback } from "react"; +import { Button } from "@repo/ui/components/ui/button"; +import type { ExtractionResult, NodeType } from "~/types/extraction"; +import { NODE_TYPES, NODE_TYPE_LABELS } from "~/types/extraction"; +import { formatNodesForClipboard } from "~/utils/ai/formatClipboard"; +import { NodeCard } from "./NodeCard"; + +type ResultsStepProps = { + result: ExtractionResult; + onStartOver: () => void; +}; + +// eslint-disable-next-line @typescript-eslint/naming-convention +export const ResultsStep = ({ result, onStartOver }: ResultsStepProps) => { + const [selectedIndices, setSelectedIndices] = useState>( + () => new Set(result.nodes.keys()), + ); + const [copied, setCopied] = useState(false); + + const allSelected = selectedIndices.size === result.nodes.length; + + const toggleAll = useCallback(() => { + if (allSelected) { + setSelectedIndices(new Set()); + } else { + setSelectedIndices(new Set(result.nodes.keys())); + } + }, [allSelected, result.nodes]); + + const toggleNode = useCallback((index: number) => { + setSelectedIndices((prev) => { + const next = new Set(prev); + if (next.has(index)) { + next.delete(index); + } else { + next.add(index); + } + return next; + }); + }, []); + + const handleCopy = useCallback(() => { + // eslint-disable-next-line @typescript-eslint/naming-convention + const selected = result.nodes.filter((_n, i) => selectedIndices.has(i)); + const text = formatNodesForClipboard(selected); + void navigator.clipboard.writeText(text).then(() => { + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }); + }, [result.nodes, selectedIndices]); + + // Group nodes by type for display + const groupedNodes = useMemo(() => { + const groups: { type: NodeType; nodes: { node: (typeof result.nodes)[0]; index: number }[] }[] = []; + for (const type of NODE_TYPES) { + const matching = result.nodes + .map((node, index) => ({ node, index })) + .filter(({ node }) => node.type === type); + if (matching.length > 0) { + groups.push({ type, nodes: matching }); + } + } + return groups; + }, [result]); + + return ( +
+ {/* Paper info */} + {(result.paperTitle || result.paperAuthors?.length) && ( +
+ {result.paperTitle && ( +

+ {result.paperTitle} +

+ )} + {result.paperAuthors?.length ? ( +

+ {result.paperAuthors.join(", ")} +

+ ) : null} +
+ )} + + {/* Controls */} +
+
+ + + {selectedIndices.size} of {result.nodes.length} nodes selected + +
+
+ + {/* Grouped nodes */} +
+ {groupedNodes.map(({ type, nodes }) => ( +
+

+ {NODE_TYPE_LABELS[type]}s ({nodes.length}) +

+
+ {nodes.map(({ node, index }) => ( + toggleNode(index)} + /> + ))} +
+
+ ))} +
+ + {/* Footer */} +
+ + +
+
+ ); +}; diff --git a/apps/website/app/(ai)/ai/components/Sidebar.tsx b/apps/website/app/(ai)/ai/components/Sidebar.tsx new file mode 100644 index 000000000..e3e303f76 --- /dev/null +++ b/apps/website/app/(ai)/ai/components/Sidebar.tsx @@ -0,0 +1,392 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; +import type { ModelInfo } from "~/api/ai/models/route"; +import { + NODE_TYPES, + NODE_TYPE_COLORS, + NODE_TYPE_LABELS, +} from "~/types/extraction"; +import type { NodeType } from "~/types/extraction"; + +type SidebarProps = { + file: File | null; + onFileChange: (f: File) => void; + selectedModel: string; + onModelChange: (model: string) => void; + models: ModelInfo[]; + modelsLoading: boolean; + researchQuestion: string; + onResearchQuestionChange: (q: string) => void; + selectedTypes: Set; + onToggleType: (type: NodeType) => void; + onExtract: () => void; + extracting: boolean; + hasResults: boolean; +}; + +// eslint-disable-next-line @typescript-eslint/naming-convention +export const Sidebar = ({ + file, + onFileChange, + selectedModel, + onModelChange, + models, + modelsLoading, + researchQuestion, + onResearchQuestionChange, + selectedTypes, + onToggleType, + onExtract, + extracting, + hasResults, +}: SidebarProps) => { + const inputRef = useRef(null); + const modelMenuRef = useRef(null); + const [dragOver, setDragOver] = useState(false); + const [isModelMenuOpen, setIsModelMenuOpen] = useState(false); + + const handleFileInput = (event: React.ChangeEvent) => { + const uploadedFile = event.target.files?.[0]; + if (uploadedFile?.type === "application/pdf") { + onFileChange(uploadedFile); + } + }; + + const handleDrop = (event: React.DragEvent) => { + event.preventDefault(); + setDragOver(false); + const droppedFile = event.dataTransfer.files[0]; + if (droppedFile?.type === "application/pdf") { + onFileChange(droppedFile); + } + }; + + const canExtract = + Boolean(file) && + selectedTypes.size > 0 && + Boolean(selectedModel) && + !extracting; + + const extractHint = !file + ? "Upload a PDF to get started." + : selectedTypes.size === 0 + ? "Choose at least one node type." + : !selectedModel + ? "Select a model to continue." + : "Ready to run extraction."; + + const sectionLabelClass = + "mb-3 block px-1 text-[18px] font-semibold tracking-[-0.016em] text-slate-800"; + const controlClassName = + "w-full rounded-xl border border-slate-300 bg-white text-[16px] text-slate-700 shadow-[inset_0_1px_0_rgba(255,255,255,0.7)] transition-all hover:border-slate-400 focus:border-slate-500 focus:outline-none focus:ring-2 focus:ring-slate-200"; + const selectedModelInfo = + models.find((model) => model.id === selectedModel) ?? models[0]; + + useEffect(() => { + if (!isModelMenuOpen) { + return; + } + + const handleClickOutside = (event: MouseEvent) => { + if (modelMenuRef.current?.contains(event.target as Node)) { + return; + } + setIsModelMenuOpen(false); + }; + + const handleEscape = (event: KeyboardEvent) => { + if (event.key === "Escape") { + setIsModelMenuOpen(false); + } + }; + + document.addEventListener("mousedown", handleClickOutside); + document.addEventListener("keydown", handleEscape); + + return () => { + document.removeEventListener("mousedown", handleClickOutside); + document.removeEventListener("keydown", handleEscape); + }; + }, [isModelMenuOpen]); + + return ( +