SPB Git
17.2 KB · 152 lines tsx
1"use client";2import { useEffect, useMemo, useRef, useState } from "react";3import { useSearchParams } from "next/navigation";4import useSWR, { mutate } from "swr";5import { Telescope, Play, Square, ExternalLink, FileDown, FileText, Printer, Trash2, CheckCircle2, Circle, Loader2, XCircle, MessageSquare, Link2, ChevronRight } from "lucide-react";6import { api, fetcher, readSSE, fmtUsd, relTime } from "@/lib/client/fetcher";7import { useModels, shortName } from "@/lib/client/use-models";8import type { ProjectDTO } from "@/lib/client/types";9import { Markdown } from "../chat/markdown";10import { cn, Button, Badge, Spinner, toast, Empty } from "../ui";1112type Depth = "quick" | "standard" | "deep";13interface Run { id: string; conversation_id: string | null; question: string; model_id: string; status: string; cost_usd: number; created_at: string; completed_at: string | null; source_count?: number }14interface RunFull extends Run { plan: { subquestions: string[]; queries: string[]; evidence_needed?: string[] } | null; steps: { step: string; at: string; detail?: unknown }[]; sources: Source[]; report_md: string | null; error: string | null }15interface Source { n: number; url: string; title: string; snippet?: string; publishedAt?: string; fetched: boolean; evidence?: { claim: string; quote: string; confidence: string }[]; evidenceCount?: number }1617const STAGES = [{ id: "plan", label: "Plan" }, { id: "discovery", label: "Découverte" }, { id: "retrieval", label: "Récupération" }, { id: "extraction", label: "Extraction" }, { id: "validation", label: "Validation croisée" }, { id: "synthesis", label: "Synthèse" }, { id: "report", label: "Rapport" }] as const;18const DEPTHS: { id: Depth; label: string; hint: string }[] = [{ id: "quick", label: "Rapide", hint: "≈ 3 requêtes · 4 pages · ~1 min" }, { id: "standard", label: "Standard", hint: "≈ 5 requêtes · 7 pages · 2-4 min" }, { id: "deep", label: "Approfondie", hint: "≈ 8 requêtes · 12 pages · 5-10 min" }];19const STATUS_TONE: Record<string, "ok" | "warn" | "danger" | "accent" | "neutral"> = { complete: "ok", error: "danger", cancelled: "neutral", planning: "accent", searching: "accent", reading: "accent", synthesizing: "accent" };20const STATUS_LABEL: Record<string, string> = { complete: "terminé", error: "erreur", cancelled: "annulé", planning: "planification", searching: "recherche", reading: "lecture", synthesizing: "synthèse" };2122interface Live { id?: string; conversationId?: string; model?: string; stage: number; plan?: RunFull["plan"]; sources: Source[]; report: string; cost?: number; error?: string; status: "idle" | "running" | "done" | "error" }23const idle: Live = { stage: -1, sources: [], report: "", status: "idle" };2425export function ResearchView() {26  const sp = useSearchParams(); const printId = sp.get("print") === "1" ? sp.get("id") : null;27  const { chat } = useModels();28  const { data: runsData } = useSWR<{ runs: Run[] }>("/api/research", fetcher, { refreshInterval: 20_000 });29  const { data: projData } = useSWR<{ projects: ProjectDTO[] }>("/api/projects", fetcher);30  const [question, setQuestion] = useState(sp.get("q") ?? ""); const [modelId, setModelId] = useState(""); const [depth, setDepth] = useState<Depth>("standard"); const [projectId, setProjectId] = useState("");31  const [live, setLive] = useState<Live>(idle); const [selected, setSelected] = useState<string | null>(sp.get("id")); const abortRef = useRef<AbortController | null>(null);32  const { data: selData, isLoading: selLoading } = useSWR<{ run: RunFull }>(selected && live.status !== "running" ? `/api/research/${selected}` : null, fetcher);3334  const defaultModel = useMemo(() => chat.find(m => /fable-5-1|opus-5-5/.test(m.id))?.id ?? chat.find(m => /^gpt-6-astra|^gpt-5\.5$/.test(m.id))?.id ?? chat.find(m => /opus|gpt-6|gpt-5/.test(m.id))?.id ?? chat[0]?.id ?? "", [chat]);35  const effModel = modelId || defaultModel;3637  useEffect(() => { if (printId && selData?.run?.report_md) { const t = setTimeout(() => window.print(), 400); return () => clearTimeout(t); } }, [printId, selData]);3839  async function start() {40    if (question.trim().length < 5 || !effModel) return;41    const abort = new AbortController(); abortRef.current = abort;42    setSelected(null); setLive({ ...idle, status: "running", stage: 0 });43    try {44      const res = await fetch("/api/research", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ question, modelId: effModel, projectId: projectId || null, depth }), signal: abort.signal, credentials: "same-origin" });45      for await (const { event, data } of readSSE(res)) {46        const d = data as Record<string, unknown>;47        if (event === "start") setLive(l => ({ ...l, id: d.id as string, conversationId: d.conversationId as string, model: d.model as string }));48        else if (event === "step") { const step = d.step as string; const idx = { plan: 1, discovery: 2, retrieval: 3, extraction: 4 }[step] ?? 0; setLive(l => ({ ...l, stage: Math.max(l.stage, idx), plan: step === "plan" ? (d.detail as RunFull["plan"]) : l.plan })); if (step === "extraction") setTimeout(() => setLive(l => ({ ...l, stage: Math.max(l.stage, 5) })), 800); }49        else if (event === "source") setLive(l => ({ ...l, sources: [...l.sources.filter(s => s.n !== d.n), { n: d.n as number, url: d.url as string, title: d.title as string, fetched: !!d.fetched }].sort((a, b) => a.n - b.n) }));50        else if (event === "evidence") setLive(l => ({ ...l, sources: l.sources.map(s => s.n === d.n ? { ...s, evidenceCount: d.count as number } : s) }));51        else if (event === "text") setLive(l => ({ ...l, stage: Math.max(l.stage, 5), report: l.report + (d.delta as string) }));52        else if (event === "done") setLive(l => ({ ...l, stage: 6, status: "done", cost: d.costUsd as number, conversationId: (d.conversationId as string) ?? l.conversationId }));53        else if (event === "error") setLive(l => ({ ...l, status: "error", error: d.message as string }));54      }55    } catch (e) { if ((e as Error).name !== "AbortError") { setLive(l => ({ ...l, status: "error", error: (e as Error).message })); toast((e as Error).message, "error"); } else setLive(l => ({ ...l, status: "error", error: "Annulé" })); }56    finally { abortRef.current = null; void mutate("/api/research"); }57  }58  function stop() { abortRef.current?.abort(); }59  async function del(id: string) { if (!confirm("Supprimer ce rapport ?")) return; await api(`/api/research/${id}`, { method: "DELETE" }); if (selected === id) setSelected(null); void mutate("/api/research"); }6061  // ---- print mode: report only62  if (printId) {63    const r = selData?.run;64    return <div className="mx-auto max-w-3xl p-8">{!r ? <Spinner /> : <><h1 className="mb-1 text-2xl font-semibold">{r.question}</h1><p className="mb-6 text-sm text-muted">Rapport de recherche · {r.model_id} · {new Date(r.created_at).toLocaleString("fr-CA")} · {r.sources?.length ?? 0} sources</p><Markdown text={r.report_md ?? ""} /></>}</div>;65  }6667  const view: { run?: RunFull; live?: Live } = live.status !== "idle" ? { live } : selData ? { run: selData.run } : {};68  const report = view.live ? view.live.report : view.run?.report_md ?? "";69  const sources = view.live ? view.live.sources : view.run?.sources ?? [];70  const plan = view.live ? view.live.plan : view.run?.plan;71  const stage = view.live ? view.live.stage : view.run ? (view.run.status === "complete" ? 6 : view.run.status === "error" ? -1 : ({ planning: 0, searching: 1, reading: 3, synthesizing: 5 }[view.run.status] ?? 0)) : -1;72  const runId = view.live?.id ?? view.run?.id; const convId = view.live?.conversationId ?? view.run?.conversation_id;73  const cost = view.live?.cost ?? view.run?.cost_usd;74  const running = live.status === "running";7576  return (77    <div className="flex min-h-0 flex-1">78      {/* history */}79      <aside className="hidden w-72 shrink-0 flex-col border-r border-border lg:flex">80        <div className="border-b border-border px-4 py-3 text-[12px] font-semibold uppercase tracking-wider text-muted">Rapports</div>81        <div className="min-h-0 flex-1 overflow-y-auto p-2">82          {(runsData?.runs ?? []).map(r => <button key={r.id} onClick={() => { if (running) return; setLive(idle); setSelected(r.id); }} className={cn("group mb-1 flex w-full flex-col gap-1 rounded-lg px-3 py-2 text-left hover:bg-surface-2", selected === r.id && !running && "bg-surface-2")}><span className="truncate-2 text-[13px]">{r.question}</span><span className="flex items-center gap-2 text-[11px] text-muted"><Badge tone={STATUS_TONE[r.status] ?? "neutral"}>{STATUS_LABEL[r.status] ?? r.status}</Badge>{relTime(r.created_at)} · {fmtUsd(Number(r.cost_usd), 3)}<span onClick={e => { e.stopPropagation(); void del(r.id); }} className="ml-auto hidden text-muted hover:text-danger group-hover:inline"><Trash2 className="h-3 w-3" /></span></span></button>)}83          {runsData && !runsData.runs.length && <div className="px-3 py-6 text-center text-[12px] text-muted">Aucun rapport pour l'instant.</div>}84        </div>85      </aside>8687      <div className="min-h-0 flex-1 overflow-y-auto">88        <div className="mx-auto w-full max-w-4xl px-4 py-5 md:px-6 space-y-5">89          <div><h1 className="flex items-center gap-2 text-xl font-semibold tracking-tight"><Telescope className="h-5 w-5 text-accent" /> Recherche approfondie</h1><p className="mt-0.5 text-[13px] text-muted">Plan → découverte web → lecture des sources → extraction d'indices → validation croisée → synthèse citée. Les preuves viennent des sources citées ; l'interprétation et la spéculation sont celles du modèle et sont étiquetées comme telles.</p></div>9091          {/* form */}92          <section className="card p-4 space-y-3">93            <textarea value={question} onChange={e => setQuestion(e.target.value)} rows={3} disabled={running} placeholder="Question de recherche — ex. : Quel est l'impact des taux hypothécaires de 2025-2026 sur les prix des plex à Gatineau ?" className="input text-[15px]" />94            <div className="flex flex-wrap items-center gap-2">95              <select value={effModel} onChange={e => setModelId(e.target.value)} disabled={running} className="input !w-auto max-w-64 !py-1.5 text-[13px]">{chat.map(m => <option key={m.id} value={m.id}>{m.displayName}{m.pricing?.output != null ? ` · ${m.pricing.input} $/${m.pricing.output} $` : ""}</option>)}</select>96              <select value={projectId} onChange={e => setProjectId(e.target.value)} disabled={running} className="input !w-auto max-w-48 !py-1.5 text-[13px]"><option value="">Sans projet</option>{(projData?.projects ?? []).map(p => <option key={p.id} value={p.id}>{p.name}</option>)}</select>97              <div className="inline-flex rounded-lg border border-border bg-surface p-0.5">{DEPTHS.map(d => <button key={d.id} disabled={running} onClick={() => setDepth(d.id)} title={d.hint} className={cn("rounded-md px-2.5 py-1 text-[12px] font-medium", depth === d.id ? "bg-surface-2 text-fg" : "text-muted hover:text-fg")}>{d.label}</button>)}</div>98              <span className="text-[11px] text-muted">{DEPTHS.find(d => d.id === depth)!.hint}</span>99              <div className="flex-1" />100              {running ? <Button variant="danger" onClick={stop}><Square className="h-3.5 w-3.5" /> Arrêter</Button> : <Button variant="primary" onClick={start} disabled={question.trim().length < 5 || !effModel}><Play className="h-3.5 w-3.5" /> Lancer</Button>}101            </div>102          </section>103104          {(view.live || view.run || selLoading) && (105            <>106              {/* pipeline */}107              <section className="card p-4">108                <ol className="flex flex-wrap items-center gap-1 text-[12px]">109                  {STAGES.map((s, i) => { const done = stage > i || stage === 6; const cur = stage === i && running; return <li key={s.id} className="flex items-center gap-1"><span className={cn("inline-flex items-center gap-1.5 rounded-md px-2 py-1", done ? "text-ok" : cur ? "bg-accent/10 text-accent" : "text-muted")}>{done ? <CheckCircle2 className="h-3.5 w-3.5" /> : cur ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Circle className="h-3.5 w-3.5" />}{s.label}</span>{i < STAGES.length - 1 && <ChevronRight className="h-3 w-3 text-muted/50" />}</li>; })}110                </ol>111                {(view.live?.error || view.run?.error) && <div className="mt-3 flex items-center gap-2 rounded-lg border border-danger/30 bg-danger/10 px-3 py-2 text-[13px] text-danger"><XCircle className="h-4 w-4" /> {view.live?.error ?? view.run?.error}</div>}112              </section>113114              <div className="grid gap-4 lg:grid-cols-3">115                {/* plan */}116                <section className="card p-4 text-[13px]">117                  <h2 className="mb-2 text-[14px] font-medium">Plan</h2>118                  {plan ? <><div className="mb-1 text-[11px] uppercase tracking-wider text-muted">Sous-questions</div><ol className="list-decimal space-y-1 pl-4 text-fg-2">{plan.subquestions?.map((q, i) => <li key={i}>{q}</li>)}</ol><div className="mb-1 mt-3 text-[11px] uppercase tracking-wider text-muted">Requêtes</div><ul className="space-y-1">{plan.queries?.map((q, i) => <li key={i} className="rounded-md bg-surface-2 px-2 py-1 text-[12px] text-fg-2">{q}</li>)}</ul></> : <div className="flex items-center gap-2 text-muted"><Spinner /> Planification…</div>}119                </section>120                {/* sources */}121                <section className="card p-4 text-[13px] lg:col-span-2">122                  <h2 className="mb-2 flex items-center justify-between text-[14px] font-medium">Sources <span className="text-[11px] font-normal text-muted">{sources.filter(s => s.fetched).length} lues / {sources.length}</span></h2>123                  {sources.length ? <ul className="max-h-80 space-y-1 overflow-y-auto pr-1">{sources.map(s => { const ev = s.evidenceCount ?? s.evidence?.length; return <li key={s.n} className="flex items-start gap-2 rounded-lg px-2 py-1.5 hover:bg-surface-2"><span className="mt-0.5 w-6 shrink-0 text-right text-[11px] tabular-nums text-muted">[{s.n}]</span><span className="min-w-0 flex-1"><a href={s.url} target="_blank" rel="noopener noreferrer" className="block truncate font-medium hover:text-accent">{s.title || s.url}</a><span className="block truncate text-[11px] text-muted">{s.url}</span></span>{s.fetched ? <Badge tone={ev ? "ok" : "neutral"}>{ev != null ? `${ev} indice${ev > 1 ? "s" : ""}` : "lue"}</Badge> : <Badge>non lue</Badge>}<a href={s.url} target="_blank" rel="noopener noreferrer" className="text-muted hover:text-fg"><ExternalLink className="h-3.5 w-3.5" /></a></li>; })}</ul> : <div className="flex items-center gap-2 text-muted">{running ? <><Spinner /> Recherche en cours…</> : "Aucune source."}</div>}124                </section>125              </div>126127              {/* report */}128              <section className="card p-5 md:p-7">129                <div className="mb-4 flex flex-wrap items-center gap-2 no-print">130                  <h2 className="text-[14px] font-medium">Rapport</h2>131                  {view.live?.model || view.run?.model_id ? <Badge tone="accent">{shortName(chat.find(m => m.id === (view.live?.model ?? view.run?.model_id)), view.live?.model ?? view.run?.model_id)}</Badge> : null}132                  {cost != null && <span className="text-[12px] text-muted">{fmtUsd(Number(cost), 3)}</span>}133                  <div className="flex-1" />134                  {runId && report && !running && <>135                    <a href={`/api/research/${runId}?format=md`} className="inline-flex items-center gap-1 rounded-lg border border-border px-2.5 py-1 text-[12px] hover:bg-surface-2"><FileText className="h-3.5 w-3.5" /> MD</a>136                    <a href={`/api/research/${runId}?format=docx`} className="inline-flex items-center gap-1 rounded-lg border border-border px-2.5 py-1 text-[12px] hover:bg-surface-2"><FileDown className="h-3.5 w-3.5" /> DOCX</a>137                    <a href={`/research?id=${runId}&print=1`} target="_blank" rel="noopener" className="inline-flex items-center gap-1 rounded-lg border border-border px-2.5 py-1 text-[12px] hover:bg-surface-2"><Printer className="h-3.5 w-3.5" /> PDF</a>138                    {convId && <a href={`/c/${convId}`} className="inline-flex items-center gap-1 rounded-lg border border-border px-2.5 py-1 text-[12px] hover:bg-surface-2"><MessageSquare className="h-3.5 w-3.5" /> Ouvrir la conversation</a>}139                  </>}140                </div>141                {report ? <Markdown text={report} /> : running ? <div className="flex items-center gap-2 py-6 text-sm text-muted"><Spinner /> {stage < 5 ? "Collecte des preuves…" : "Rédaction de la synthèse…"}</div> : selLoading ? <div className="skeleton h-40 rounded-xl" /> : <div className="py-6 text-sm text-muted">Pas de rapport.</div>}142                {report && <p className="mt-6 flex items-start gap-2 border-t border-border pt-3 text-[11px] text-muted"><Link2 className="mt-0.5 h-3 w-3 shrink-0" /> Les affirmations suivies de [n] s'appuient sur les sources numérotées ci-dessus. Les passages étiquetés « Interprétation » ou « Spéculation » sont des jugements du modèle, pas des faits observés.</p>}143              </section>144            </>145          )}146          {!view.live && !view.run && !selLoading && <Empty icon={<Telescope className="h-8 w-8" />} title="Lance une recherche ou ouvre un rapport" hint="Les rapports incluent des sources vérifiables et distinguent preuves, interprétation et spéculation." />}147        </div>148      </div>149    </div>150  );151}152
Files · laptop-build-20261101
Menu

Theme