SPB Git
15.3 KB · 119 lines tsx
1"use client";2/** AI features of the research workspace. Every result is labelled AI-generated and only enters the page/notes/tasks on explicit user action. */3import { useRef, useState } from "react";4import { useQuery, useQueryClient } from "@tanstack/react-query";5import { BookOpen, GitCompare, HelpCircle, Sigma, PenLine, ShieldCheck, ListChecks, Square, FilePlus2, Copy, StickyNote, Sparkles, X } from "lucide-react";6import { useRouter } from "next/navigation";7import { api, sse } from "@/lib/client/api";8import { Markdown } from "@/components/markdown";9import { Button } from "@/components/ui/button";10import { Input, Textarea } from "@/components/ui/input";11import { Badge, Spinner } from "@/components/ui/primitives";12import { toast } from "@/components/ui/toast";13import { AILabel, Select, insertMarkdown, saveAsNote, copy } from "./ui";14import { refsKey, attKey } from "./ReferencesTab";15import { taskKey } from "./TasksTab";16import { cn } from "@/lib/utils";1718type Feature = "synthesis" | "compare" | "question" | "equation" | "draft" | "refcheck" | "plan";19const FEATURES: { id: Feature; label: string; hint: string; icon: React.ReactNode; task: string }[] = [20  { id: "synthesis", label: "Literature synthesis", hint: "Across the page's references, abstracts and attached PDFs", icon: <BookOpen />, task: "deep" },21  { id: "compare", label: "Compare methodologies", hint: "Side-by-side of two or more references / files", icon: <GitCompare />, task: "reason" },22  { id: "question", label: "Refine research question", hint: "Sharper formulations, hypotheses, identification", icon: <HelpCircle />, task: "reason" },23  { id: "equation", label: "Explain an equation", hint: "Symbols, intuition, derivation, estimation", icon: <Sigma />, task: "reason" },24  { id: "draft", label: "Edit a section", hint: "Journal-standard revision of a selected passage", icon: <PenLine />, task: "write" },25  { id: "refcheck", label: "Check references", hint: "Flags citations that match no stored reference", icon: <ShieldCheck />, task: "reason" },26  { id: "plan", label: "Plan experiments", hint: "Structured plan with tasks you can accept", icon: <ListChecks />, task: "deep" },27];28interface Result { feature: Feature; text: string; model?: string; reason?: string; context?: any; data?: any; costUsd?: number | null; latencyMs?: number; streaming: boolean; error?: string }2930export function AITab({ pageId, pageTitle }: { pageId: string; pageTitle: string }) {31  const r = useRouter(); const qc = useQueryClient();32  const { data: refs = [] } = useQuery<any[]>({ queryKey: refsKey(pageId), queryFn: () => api(`/api/research/references?pageId=${pageId}`) });33  const { data: files = [] } = useQuery<any[]>({ queryKey: attKey(pageId), queryFn: () => api(`/api/attachments?pageId=${pageId}`) });34  const [feature, setFeature] = useState<Feature>("synthesis"); const [lang, setLang] = useState<"" | "fr" | "en">("");35  const [sel, setSel] = useState<string[]>([]); const [selFiles, setSelFiles] = useState<string[]>([]); const [question, setQuestion] = useState(""); const [latex, setLatex] = useState(""); const [text, setText] = useState(""); const [instruction, setInstruction] = useState("");36  const [res, setRes] = useState<Result | null>(null); const [accepted, setAccepted] = useState<Record<number, boolean>>({}); const [acceptBusy, setAcceptBusy] = useState(false);37  const abort = useRef<AbortController | null>(null);38  const busy = res?.streaming ?? false;39  const toggle = (list: string[], set: (v: string[]) => void, id: string) => set(list.includes(id) ? list.filter((x) => x !== id) : [...list, id]);40  const grabSelection = () => { const s = window.getSelection()?.toString().trim(); if (s) { setText(s); toast("Selection captured"); } else toast("Select text in the page first"); };41  const canRun = feature === "compare" ? sel.length + selFiles.length >= 2 : feature === "equation" ? latex.trim().length > 0 : feature === "draft" ? text.trim().length > 0 : true;42  async function run() {43    if (busy || !canRun) return;44    const body: any = { feature, pageId, language: lang || null };45    if (feature === "compare") { body.referenceIds = sel; body.attachmentIds = selFiles; body.instruction = instruction || undefined; }46    if (feature === "synthesis") { body.referenceIds = sel.length ? sel : undefined; body.attachmentIds = selFiles.length ? selFiles : undefined; body.question = question || undefined; }47    if (feature === "question") body.question = question || undefined;48    if (feature === "equation") { body.latex = latex; body.instruction = instruction || undefined; }49    if (feature === "draft") { body.text = text; body.instruction = instruction || undefined; }50    if (feature === "plan") body.instruction = instruction || undefined;51    setRes({ feature, text: "", streaming: true }); setAccepted({});52    const ac = new AbortController(); abort.current = ac;53    await sse("/api/research/ai", body, (ev) => {54      if (ev.type === "meta") setRes((s) => s && { ...s, model: ev.model, reason: ev.reason, context: ev.context });55      else if (ev.type === "text") setRes((s) => s && { ...s, text: s.text + ev.delta });56      else if (ev.type === "result") setRes((s) => s && { ...s, data: ev.data });57      else if (ev.type === "done") setRes((s) => s && { ...s, streaming: false, costUsd: ev.costUsd, latencyMs: ev.latencyMs });58      else if (ev.type === "error") { toast("AI error", { description: ev.message, tone: "danger" }); setRes((s) => s && { ...s, streaming: false, error: ev.message }); }59    }, ac.signal).catch(() => {});60    setRes((s) => s && { ...s, streaming: false });61  }62  async function acceptTasks() {63    const items = (res?.data?.tasks ?? []).filter((_: any, i: number) => accepted[i] !== false);64    if (!items.length) return; setAcceptBusy(true);65    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"] }); }66    catch (e: any) { toast("Could not add tasks", { description: e.message, tone: "danger" }); } finally { setAcceptBusy(false); }67  }68  const f = FEATURES.find((x) => x.id === feature)!;69  const RefPicker = ({ required }: { required?: boolean }) => (70    <div className="rounded-md border border-line bg-bg p-2">71      <div className="mb-1 text-[11px] font-medium uppercase tracking-wide text-fg-faint">{required ? "Select at least two" : "Restrict to (optional)"} — {sel.length + selFiles.length} selected</div>72      {!refs.length && !files.length && <div className="text-[12px] text-fg-muted">No references or files on this page yet.</div>}73      <div className="max-h-40 space-y-0.5 overflow-y-auto">74        {refs.map((x) => <label key={x.id} className="flex min-h-8 cursor-pointer items-center gap-2 rounded px-1 text-[12px] hover:bg-bg-sunk"><input type="checkbox" checked={sel.includes(x.id)} onChange={() => toggle(sel, setSel, x.id)} className="accent-[var(--accent)]" /><span className="font-mono text-accent">{x.citeKey}</span><span className="truncate text-fg-muted">{x.title}</span>{x.attachmentId && <Badge tone="neutral">PDF</Badge>}</label>)}75        {files.filter((x) => x.hasText && !refs.some((rr) => rr.attachmentId === x.id)).map((x) => <label key={x.id} className="flex min-h-8 cursor-pointer items-center gap-2 rounded px-1 text-[12px] hover:bg-bg-sunk"><input type="checkbox" checked={selFiles.includes(x.id)} onChange={() => toggle(selFiles, setSelFiles, x.id)} className="accent-[var(--accent)]" /><span className="truncate">📎 {x.filename}</span></label>)}76      </div>77    </div>78  );79  return (80    <div className="grid gap-3 lg:grid-cols-[17rem_1fr]">81      <div>82        <div className="grid grid-cols-2 gap-1 sm:grid-cols-3 lg:grid-cols-1">{FEATURES.map((x) => <button key={x.id} onClick={() => { setFeature(x.id); }} className={cn("flex min-h-10 items-center gap-2 rounded-md border px-2 py-1.5 text-left text-[12px] [&_svg]:size-4 [&_svg]:shrink-0", feature === x.id ? "border-ai/40 bg-ai-soft text-fg" : "border-line bg-bg-elev text-fg-muted hover:bg-bg-sunk")}>{x.icon}<span className="min-w-0"><span className="block truncate font-medium">{x.label}</span><span className="hidden truncate text-[11px] text-fg-faint lg:block">{x.hint}</span></span></button>)}</div>83        <div className="mt-2 flex items-center gap-1 text-[11px] text-fg-faint"><span>Language</span><Select value={lang} onChange={(e) => setLang(e.target.value as any)} className="h-7 text-[11px]"><option value="">auto</option><option value="fr">français</option><option value="en">English</option></Select><span className="ml-auto">route: {f.task}</span></div>84      </div>85      <div className="min-w-0">86        <div className="rounded-lg border border-line bg-bg-elev p-3">87          <div className="mb-2 flex items-center gap-2 text-[13px] font-medium [&_svg]:size-4 [&_svg]:text-ai">{f.icon}{f.label}<span className="hidden text-[12px] font-normal text-fg-faint sm:inline">— {f.hint}</span></div>88          <div className="space-y-2">89            {feature === "synthesis" && <><RefPicker /><Input value={question} onChange={(e) => setQuestion(e.target.value)} placeholder="Optional focus (e.g. identification of bailout effects on bank size)" className="h-8 text-[13px]" /></>}90            {feature === "compare" && <><RefPicker required /><Input value={instruction} onChange={(e) => setInstruction(e.target.value)} placeholder="Optional focus (e.g. identification strategy only)" className="h-8 text-[13px]" /></>}91            {feature === "question" && <Textarea value={question} onChange={(e) => setQuestion(e.target.value)} placeholder="Your current research question (leave empty to derive it from the page)" className="min-h-[70px] text-[13px]" />}92            {feature === "equation" && <><Textarea value={latex} onChange={(e) => setLatex(e.target.value)} placeholder={"LaTeX, e.g. y_{it} = \\alpha_i + \\beta' x_{it} + \\varepsilon_{it}"} className="min-h-[70px] font-mono text-[12px]" /><div className="flex gap-1"><Button size="xs" variant="outline" onClick={() => { const s = window.getSelection()?.toString().trim(); if (s) setLatex(s); else toast("Select an equation in the page first"); }}>Use selection</Button></div><Input value={instruction} onChange={(e) => setInstruction(e.target.value)} placeholder="Optional focus" className="h-8 text-[13px]" /></>}93            {feature === "draft" && <><Textarea value={text} onChange={(e) => setText(e.target.value)} placeholder="Paste the section to edit, or select it in the page and click “Use selection”" className="min-h-[110px] text-[13px]" /><div className="flex gap-1"><Button size="xs" variant="outline" onClick={grabSelection}>Use selection</Button>{text && <Button size="xs" variant="ghost" onClick={() => setText("")}><X /> Clear</Button>}</div><Input value={instruction} onChange={(e) => setInstruction(e.target.value)} placeholder="Instruction (default: tighten and polish to journal standard)" className="h-8 text-[13px]" /></>}94            {feature === "refcheck" && <div className="text-[12px] text-fg-muted">Scans the page for <code className="font-mono">\cite{"{key}"}</code> and <i>Author (Year)</i> patterns and asks the model whether each one corresponds to a stored reference ({refs.length} on this page). Anything not matched is reported as <b>unverified</b>; no citation is ever invented.</div>}95            {feature === "plan" && <Textarea value={instruction} onChange={(e) => setInstruction(e.target.value)} placeholder="Optional constraints (data available, deadline, compute, what is already done)" className="min-h-[60px] text-[13px]" />}96          </div>97          <div className="mt-3 flex items-center gap-2">{busy ? <Button size="sm" variant="secondary" onClick={() => abort.current?.abort()}><Square /> Stop</Button> : <Button size="sm" variant="ai" onClick={run} disabled={!canRun}><Sparkles /> Run</Button>}<span className="text-[11px] text-fg-faint">Output is a suggestion; nothing is written to the page until you insert it.</span></div>98        </div>99        {res && (100          <div className="mt-3 rounded-lg border border-ai/30 bg-bg-elev">101            <div className="flex flex-wrap items-center gap-1.5 border-b border-line px-3 py-2 text-[11px] text-fg-faint"><AILabel model={res.model} />{res.context && <span>{res.context.references} refs · {res.context.attachments} files · {res.context.sources} passages</span>}{res.reason && <span className="hidden sm:inline">· {res.reason}</span>}{res.latencyMs != null && !res.streaming && <span>· {(res.latencyMs / 1000).toFixed(1)} s</span>}{res.costUsd != null && <span>· ${res.costUsd.toFixed(4)}</span>}{res.streaming && <Spinner className="ml-1 size-3" />}102              {!res.streaming && res.text && <span className="ml-auto flex gap-0.5"><button className="flex h-7 items-center gap-1 rounded px-1.5 hover:bg-bg-sunk hover:text-fg" onClick={() => { insertMarkdown(res.text); }} title="Insert into page as AI block"><FilePlus2 className="size-3.5" /> Insert into page</button><button className="flex h-7 items-center gap-1 rounded px-1.5 hover:bg-bg-sunk hover:text-fg" onClick={async () => { const p = await saveAsNote(res.text, `${f.label} — ${pageTitle}`, res.model); qc.invalidateQueries({ queryKey: ["tree"] }); r.push(`/p/${p.id}`); }} title="Save as note"><StickyNote className="size-3.5" /> Save as note</button><button className="flex h-7 items-center gap-1 rounded px-1.5 hover:bg-bg-sunk hover:text-fg" onClick={() => copy(res.text)} title="Copy"><Copy className="size-3.5" /></button></span>}103            </div>104            <div className="max-h-[60vh] overflow-y-auto px-3 py-2"><Markdown text={res.text || (res.streaming ? "…" : res.error ? `⚠️ ${res.error}` : "")} />{res.streaming && res.text && <span className="pulse-soft text-fg-faint">▍</span>}</div>105            {res.feature === "plan" && res.data?.tasks?.length > 0 && !res.streaming && (106              <div className="border-t border-line px-3 py-2">107                <div className="mb-1 text-[12px] font-medium">Accept proposed tasks into the Tasks tab</div>108                <div className="space-y-0.5">{res.data.tasks.map((t: any, i: number) => <label key={i} className="flex min-h-8 cursor-pointer items-center gap-2 text-[12px]"><input type="checkbox" checked={accepted[i] !== false} onChange={(e) => setAccepted((a) => ({ ...a, [i]: e.target.checked }))} className="accent-[var(--accent)]" /><span className="flex-1">{t.title}</span><Badge tone={t.priority === "high" ? "warning" : "neutral"}>{t.priority}</Badge>{t.dueInDays != null && <span className="text-fg-faint">~{t.dueInDays} d</span>}</label>)}</div>109                <Button size="sm" className="mt-2" onClick={acceptTasks} disabled={acceptBusy}>{acceptBusy ? <Spinner className="size-3.5" /> : <ListChecks />} Add {res.data.tasks.filter((_: any, i: number) => accepted[i] !== false).length} tasks</Button>110              </div>111            )}112            {res.feature === "refcheck" && res.data && !res.streaming && <div className="border-t border-line px-3 py-2 text-[11px] text-fg-faint">{res.data.found} citations found · {res.data.matched} matched · <span className={res.data.unverified ? "text-warning" : ""}>{res.data.unverified} unverified</span>. Add missing references by DOI or BibTeX in the References tab.</div>}113          </div>114        )}115      </div>116    </div>117  );118}119
Files · main
Menu

Theme