"use client"; import { useEffect, useMemo, useRef, useState } from "react"; import { useSearchParams } from "next/navigation"; import useSWR, { mutate } from "swr"; import { Telescope, Play, Square, ExternalLink, FileDown, FileText, Printer, Trash2, CheckCircle2, Circle, Loader2, XCircle, MessageSquare, Link2, ChevronRight } from "lucide-react"; import { api, fetcher, readSSE, fmtUsd, relTime } from "@/lib/client/fetcher"; import { useModels, shortName } from "@/lib/client/use-models"; import type { ProjectDTO } from "@/lib/client/types"; import { Markdown } from "../chat/markdown"; import { cn, Button, Badge, Spinner, toast, Empty } from "../ui"; type Depth = "quick" | "standard" | "deep"; interface 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 } interface 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 } interface Source { n: number; url: string; title: string; snippet?: string; publishedAt?: string; fetched: boolean; evidence?: { claim: string; quote: string; confidence: string }[]; evidenceCount?: number } const 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; const 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" }]; const STATUS_TONE: Record = { complete: "ok", error: "danger", cancelled: "neutral", planning: "accent", searching: "accent", reading: "accent", synthesizing: "accent" }; const STATUS_LABEL: Record = { complete: "terminé", error: "erreur", cancelled: "annulé", planning: "planification", searching: "recherche", reading: "lecture", synthesizing: "synthèse" }; interface 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" } const idle: Live = { stage: -1, sources: [], report: "", status: "idle" }; export function ResearchView() { const sp = useSearchParams(); const printId = sp.get("print") === "1" ? sp.get("id") : null; const { chat } = useModels(); const { data: runsData } = useSWR<{ runs: Run[] }>("/api/research", fetcher, { refreshInterval: 20_000 }); const { data: projData } = useSWR<{ projects: ProjectDTO[] }>("/api/projects", fetcher); const [question, setQuestion] = useState(sp.get("q") ?? ""); const [modelId, setModelId] = useState(""); const [depth, setDepth] = useState("standard"); const [projectId, setProjectId] = useState(""); const [live, setLive] = useState(idle); const [selected, setSelected] = useState(sp.get("id")); const abortRef = useRef(null); const { data: selData, isLoading: selLoading } = useSWR<{ run: RunFull }>(selected && live.status !== "running" ? `/api/research/${selected}` : null, fetcher); 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]); const effModel = modelId || defaultModel; useEffect(() => { if (printId && selData?.run?.report_md) { const t = setTimeout(() => window.print(), 400); return () => clearTimeout(t); } }, [printId, selData]); async function start() { if (question.trim().length < 5 || !effModel) return; const abort = new AbortController(); abortRef.current = abort; setSelected(null); setLive({ ...idle, status: "running", stage: 0 }); try { 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" }); for await (const { event, data } of readSSE(res)) { const d = data as Record; if (event === "start") setLive(l => ({ ...l, id: d.id as string, conversationId: d.conversationId as string, model: d.model as string })); 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); } 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) })); else if (event === "evidence") setLive(l => ({ ...l, sources: l.sources.map(s => s.n === d.n ? { ...s, evidenceCount: d.count as number } : s) })); else if (event === "text") setLive(l => ({ ...l, stage: Math.max(l.stage, 5), report: l.report + (d.delta as string) })); else if (event === "done") setLive(l => ({ ...l, stage: 6, status: "done", cost: d.costUsd as number, conversationId: (d.conversationId as string) ?? l.conversationId })); else if (event === "error") setLive(l => ({ ...l, status: "error", error: d.message as string })); } } 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é" })); } finally { abortRef.current = null; void mutate("/api/research"); } } function stop() { abortRef.current?.abort(); } 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"); } // ---- print mode: report only if (printId) { const r = selData?.run; return
{!r ? : <>

{r.question}

Rapport de recherche · {r.model_id} · {new Date(r.created_at).toLocaleString("fr-CA")} · {r.sources?.length ?? 0} sources

}
; } const view: { run?: RunFull; live?: Live } = live.status !== "idle" ? { live } : selData ? { run: selData.run } : {}; const report = view.live ? view.live.report : view.run?.report_md ?? ""; const sources = view.live ? view.live.sources : view.run?.sources ?? []; const plan = view.live ? view.live.plan : view.run?.plan; 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; const runId = view.live?.id ?? view.run?.id; const convId = view.live?.conversationId ?? view.run?.conversation_id; const cost = view.live?.cost ?? view.run?.cost_usd; const running = live.status === "running"; return (
{/* history */}

Recherche approfondie

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.

{/* form */}