"use client"; /** AI features of the research workspace. Every result is labelled AI-generated and only enters the page/notes/tasks on explicit user action. */ import { useRef, useState } from "react"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { BookOpen, GitCompare, HelpCircle, Sigma, PenLine, ShieldCheck, ListChecks, Square, FilePlus2, Copy, StickyNote, Sparkles, X } from "lucide-react"; import { useRouter } from "next/navigation"; import { api, sse } from "@/lib/client/api"; import { Markdown } from "@/components/markdown"; import { Button } from "@/components/ui/button"; import { Input, Textarea } from "@/components/ui/input"; import { Badge, Spinner } from "@/components/ui/primitives"; import { toast } from "@/components/ui/toast"; import { AILabel, Select, insertMarkdown, saveAsNote, copy } from "./ui"; import { refsKey, attKey } from "./ReferencesTab"; import { taskKey } from "./TasksTab"; import { cn } from "@/lib/utils"; type Feature = "synthesis" | "compare" | "question" | "equation" | "draft" | "refcheck" | "plan"; const FEATURES: { id: Feature; label: string; hint: string; icon: React.ReactNode; task: string }[] = [ { id: "synthesis", label: "Literature synthesis", hint: "Across the page's references, abstracts and attached PDFs", icon: , task: "deep" }, { id: "compare", label: "Compare methodologies", hint: "Side-by-side of two or more references / files", icon: , task: "reason" }, { id: "question", label: "Refine research question", hint: "Sharper formulations, hypotheses, identification", icon: , task: "reason" }, { id: "equation", label: "Explain an equation", hint: "Symbols, intuition, derivation, estimation", icon: , task: "reason" }, { id: "draft", label: "Edit a section", hint: "Journal-standard revision of a selected passage", icon: , task: "write" }, { id: "refcheck", label: "Check references", hint: "Flags citations that match no stored reference", icon: , task: "reason" }, { id: "plan", label: "Plan experiments", hint: "Structured plan with tasks you can accept", icon: , task: "deep" }, ]; interface Result { feature: Feature; text: string; model?: string; reason?: string; context?: any; data?: any; costUsd?: number | null; latencyMs?: number; streaming: boolean; error?: string } export function AITab({ pageId, pageTitle }: { pageId: string; pageTitle: string }) { const r = useRouter(); const qc = useQueryClient(); const { data: refs = [] } = useQuery({ queryKey: refsKey(pageId), queryFn: () => api(`/api/research/references?pageId=${pageId}`) }); const { data: files = [] } = useQuery({ queryKey: attKey(pageId), queryFn: () => api(`/api/attachments?pageId=${pageId}`) }); const [feature, setFeature] = useState("synthesis"); const [lang, setLang] = useState<"" | "fr" | "en">(""); const [sel, setSel] = useState([]); const [selFiles, setSelFiles] = useState([]); const [question, setQuestion] = useState(""); const [latex, setLatex] = useState(""); const [text, setText] = useState(""); const [instruction, setInstruction] = useState(""); const [res, setRes] = useState(null); const [accepted, setAccepted] = useState>({}); const [acceptBusy, setAcceptBusy] = useState(false); const abort = useRef(null); const busy = res?.streaming ?? false; const toggle = (list: string[], set: (v: string[]) => void, id: string) => set(list.includes(id) ? list.filter((x) => x !== id) : [...list, id]); const grabSelection = () => { const s = window.getSelection()?.toString().trim(); if (s) { setText(s); toast("Selection captured"); } else toast("Select text in the page first"); }; const canRun = feature === "compare" ? sel.length + selFiles.length >= 2 : feature === "equation" ? latex.trim().length > 0 : feature === "draft" ? text.trim().length > 0 : true; async function run() { if (busy || !canRun) return; const body: any = { feature, pageId, language: lang || null }; if (feature === "compare") { body.referenceIds = sel; body.attachmentIds = selFiles; body.instruction = instruction || undefined; } if (feature === "synthesis") { body.referenceIds = sel.length ? sel : undefined; body.attachmentIds = selFiles.length ? selFiles : undefined; body.question = question || undefined; } if (feature === "question") body.question = question || undefined; if (feature === "equation") { body.latex = latex; body.instruction = instruction || undefined; } if (feature === "draft") { body.text = text; body.instruction = instruction || undefined; } if (feature === "plan") body.instruction = instruction || undefined; setRes({ feature, text: "", streaming: true }); setAccepted({}); const ac = new AbortController(); abort.current = ac; await sse("/api/research/ai", body, (ev) => { if (ev.type === "meta") setRes((s) => s && { ...s, model: ev.model, reason: ev.reason, context: ev.context }); else if (ev.type === "text") setRes((s) => s && { ...s, text: s.text + ev.delta }); else if (ev.type === "result") setRes((s) => s && { ...s, data: ev.data }); else if (ev.type === "done") setRes((s) => s && { ...s, streaming: false, costUsd: ev.costUsd, latencyMs: ev.latencyMs }); else if (ev.type === "error") { toast("AI error", { description: ev.message, tone: "danger" }); setRes((s) => s && { ...s, streaming: false, error: ev.message }); } }, ac.signal).catch(() => {}); setRes((s) => s && { ...s, streaming: false }); } async function acceptTasks() { const items = (res?.data?.tasks ?? []).filter((_: any, i: number) => accepted[i] !== false); if (!items.length) return; setAcceptBusy(true); try { await api("/api/research/tasks", { method: "POST", json: { pageId, items: items.map((t: any) => ({ title: t.title, priority: t.priority, dueAt: t.dueInDays != null ? new Date(Date.now() + t.dueInDays * 864e5).toISOString() : null })) } }); toast(`${items.length} task${items.length > 1 ? "s" : ""} added`, { tone: "success" }); qc.invalidateQueries({ queryKey: taskKey(pageId) }); qc.invalidateQueries({ queryKey: ["research-overview"] }); } catch (e: any) { toast("Could not add tasks", { description: e.message, tone: "danger" }); } finally { setAcceptBusy(false); } } const f = FEATURES.find((x) => x.id === feature)!; const RefPicker = ({ required }: { required?: boolean }) => (
{required ? "Select at least two" : "Restrict to (optional)"} — {sel.length + selFiles.length} selected
{!refs.length && !files.length &&
No references or files on this page yet.
}
{refs.map((x) => )} {files.filter((x) => x.hasText && !refs.some((rr) => rr.attachmentId === x.id)).map((x) => )}
); return (
{FEATURES.map((x) => )}
Languageroute: {f.task}
{f.icon}{f.label}— {f.hint}
{feature === "synthesis" && <> setQuestion(e.target.value)} placeholder="Optional focus (e.g. identification of bailout effects on bank size)" className="h-8 text-[13px]" />} {feature === "compare" && <> setInstruction(e.target.value)} placeholder="Optional focus (e.g. identification strategy only)" className="h-8 text-[13px]" />} {feature === "question" &&