SPB Git
10.4 KB · 123 lines tsx
1"use client";2import { useEffect, useMemo, useRef, useState, useCallback } from "react";3import { useRouter } from "next/navigation";4import useSWR, { mutate as gmutate } from "swr";5import { ArrowDown, Download, Pin, FolderKanban, AlertTriangle, Sparkles, Columns2, Telescope, FileText, Code2, Lightbulb, GraduationCap } from "lucide-react";6import { useChat } from "@/lib/client/use-chat";7import { Composer, type ComposerSubmit } from "./composer";8import { Message } from "./message";9import { ArtifactPanel, type ArtifactView } from "./artifact-panel";10import { api, fetcher } from "@/lib/client/fetcher";11import type { ProjectDTO } from "@/lib/client/types";12import { cn, IconButton, toast } from "../ui";13import { setPrefs } from "@/lib/client/prefs";14import { useShell } from "../app-shell";1516const SUGGESTIONS = [17  { icon: FileText, title: "Analyser un document", text: "Résume ce document et relève les points qui méritent une vérification.", hint: "glisse un PDF, DOCX ou XLSX" },18  { icon: Code2, title: "Écrire du code", text: "Écris une fonction TypeScript qui…", hint: "aperçu HTML/React dans le panneau d'artefacts" },19  { icon: GraduationCap, title: "Évaluation immobilière", text: "Explique la méthode du coût pour un immeuble à revenus et donne un exemple chiffré au Québec.", hint: "contexte UQO" },20  { icon: Lightbulb, title: "Recherche sourcée", text: "Cherche sur le web les derniers chiffres sur … et cite tes sources.", hint: "outil web_search" },21];2223export function ChatView({ conversationId, projectId }: { conversationId: string | null; projectId?: string | null }) {24  const chat = useChat(conversationId); const router = useRouter(); const { openPalette } = useShell();25  const isDraft = !chat.conversationId;26  const { data: projData } = useSWR<{ projects: ProjectDTO[] }>("/api/projects", fetcher);27  const [artifact, setArtifact] = useState<ArtifactView | null>(null);28  const scroller = useRef<HTMLDivElement>(null); const [stuck, setStuck] = useState(true);29  const effectiveProject = chat.conversation?.project_id ?? projectId ?? null;30  const project = projData?.projects.find(p => p.id === effectiveProject);3132  // auto-scroll while streaming when the user is near the bottom33  useEffect(() => { const el = scroller.current; if (!el || !stuck) return; el.scrollTop = el.scrollHeight; }, [chat.messages, stuck]);34  const onScroll = useCallback(() => { const el = scroller.current; if (!el) return; setStuck(el.scrollHeight - el.scrollTop - el.clientHeight < 80); }, []);35  useEffect(() => { if (location.hash && chat.messages.length) { const t = document.getElementById(location.hash.slice(1)); if (t) { t.scrollIntoView({ block: "center" }); t.classList.add("ring-2", "ring-accent/40", "rounded-xl"); setTimeout(() => t.classList.remove("ring-2", "ring-accent/40"), 2500); } } }, [chat.messages.length]);3637  const siblingsFor = useMemo(() => {38    const map = new Map<string, { ids: string[]; index: number }>();39    for (const m of chat.messages) {40      const sibs = chat.allMessages.filter(x => (x.parent_id ?? null) === (m.parent_id ?? null) && x.role === m.role).sort((a, b) => a.created_at.localeCompare(b.created_at));41      if (sibs.length > 1) map.set(m.id, { ids: sibs.map(x => x.id), index: sibs.findIndex(x => x.id === m.id) });42    }43    return map;44  }, [chat.allMessages, chat.messages]);4546  async function submit(s: ComposerSubmit) {47    setStuck(true);48    await chat.send(s.text, { modelId: s.routingMode === "manual" ? s.modelId ?? undefined : s.modelId ?? undefined, routingMode: s.routingMode, settings: s.effort ? { effort: s.effort } : undefined, toolsEnabled: s.toolsEnabled, projectId: effectiveProject }, s.attachments);49  }50  async function branch(messageId: string) {51    if (!chat.conversationId) return;52    const r = await api<{ conversation: { id: string } }>(`/api/conversations/${chat.conversationId}/branch`, { method: "POST", json: { messageId } });53    void gmutate("/api/conversations"); toast("Branche créée", "ok"); router.push(`/c/${r.conversation.id}`);54  }55  const lastAssistantId = [...chat.messages].reverse().find(m => m.role === "assistant")?.id;5657  return (58    <div className="flex min-h-0 flex-1">59      <div className="flex min-w-0 flex-1 flex-col">60        {/* header */}61        <div className="flex items-center gap-2 border-b border-border/60 px-3 py-2 md:px-5 no-print">62          <div className="min-w-0 flex-1">63            <div className="flex items-center gap-2">64              {chat.conversation?.pinned && <Pin className="h-3.5 w-3.5 text-accent-2" />}65              <h1 className="truncate text-[15px] font-medium">{chat.conversation?.title ?? (chat.conversationId ? "…" : "Nouvelle conversation")}</h1>66            </div>67            {project && <div className="mt-0.5 flex items-center gap-1 text-[12px] text-muted"><FolderKanban className="h-3 w-3" /> {project.name}</div>}68          </div>69          {chat.conversation && <>70            <span className="hidden text-[12px] text-muted md:inline">{chat.conversation.message_count} msgs · {Number(chat.conversation.total_cost_usd).toFixed(3)} $</span>71            <IconButton label="Exporter en Markdown" onClick={() => window.open(`/api/conversations/${chat.conversationId}/export?format=md`)}><Download className="h-4 w-4" /></IconButton>72            <IconButton label="Comparer des modèles sur ce fil" onClick={() => router.push(`/compare?prompt=${encodeURIComponent([...chat.messages].reverse().find(m => m.role === "user")?.text ?? "")}`)}><Columns2 className="h-4 w-4" /></IconButton>73          </>}74        </div>75        {/* messages */}76        <div ref={scroller} onScroll={onScroll} className="relative min-h-0 flex-1 overflow-y-auto">77          <div className="mx-auto w-full max-w-[var(--reading-w)] px-4 py-6 md:px-6 space-y-7">78            {chat.loading && !chat.messages.length && <div className="space-y-4">{[0, 1, 2].map(i => <div key={i} className={cn("skeleton h-16 rounded-2xl", i % 2 ? "ml-auto w-2/3" : "w-full")} />)}</div>}79            {chat.error && !chat.messages.length && <div className="flex items-center gap-2 rounded-xl border border-danger/30 bg-danger/10 p-3 text-sm text-danger"><AlertTriangle className="h-4 w-4" /> {chat.error}</div>}80            {isDraft && !chat.messages.length && !chat.loading && (81              <div className="pt-[8vh] md:pt-[14vh]">82                <div className="mb-8 text-center">83                  <div className="mx-auto mb-4 inline-flex h-12 w-12 items-center justify-center rounded-2xl bg-gradient-to-br from-accent/30 to-accent-2/30 text-accent"><Sparkles className="h-6 w-6" /></div>84                  <h2 className="text-2xl font-semibold tracking-tight">Que veux-tu explorer ?</h2>85                  <p className="mt-1.5 text-sm text-muted">Tous les modèles OpenAI et Anthropic vérifiés, une seule interface privée.{project ? ` Projet « ${project.name} ».` : ""}</p>86                </div>87                <div className="grid gap-2 sm:grid-cols-2">88                  {SUGGESTIONS.map(s => <button key={s.title} onClick={() => { const ta = document.querySelector("textarea"); if (ta) { const setter = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, "value")!.set!; setter.call(ta, s.text); ta.dispatchEvent(new Event("input", { bubbles: true })); ta.focus(); } }} className="flex items-start gap-3 rounded-xl border border-border bg-surface p-3.5 text-left transition-colors hover:border-border-strong hover:bg-surface-2"><s.icon className="mt-0.5 h-4 w-4 shrink-0 text-accent" /><span><span className="block text-[13px] font-medium">{s.title}</span><span className="block text-[12px] text-muted">{s.hint}</span></span></button>)}89                </div>90                <div className="mt-6 flex flex-wrap justify-center gap-2 text-[12px] text-muted">91                  <button onClick={() => router.push("/compare")} className="inline-flex items-center gap-1 rounded-lg border border-border px-2.5 py-1 hover:bg-surface-2"><Columns2 className="h-3.5 w-3.5" /> Comparer des modèles</button>92                  <button onClick={() => router.push("/research")} className="inline-flex items-center gap-1 rounded-lg border border-border px-2.5 py-1 hover:bg-surface-2"><Telescope className="h-3.5 w-3.5" /> Recherche approfondie</button>93                  <button onClick={openPalette} className="inline-flex items-center gap-1 rounded-lg border border-border px-2.5 py-1 hover:bg-surface-2">⌘K Rechercher</button>94                </div>95              </div>96            )}97            {chat.messages.map(m => {98              const sib = siblingsFor.get(m.id);99              return <Message key={m.id} m={m} streaming={chat.streaming} conversationId={chat.conversationId} actions={{100                onRegenerate: m.role === "assistant" && m.id === lastAssistantId ? id => chat.regenerate(id) : undefined,101                onEdit: m.role === "user" ? (id, text) => chat.edit(id, text) : undefined,102                onBranch: chat.conversationId ? branch : undefined,103                onApprove: (mid, a, d) => chat.approve(mid, a, d),104                onOpenArtifact: setArtifact,105                siblings: sib,106                onSwitchSibling: sib ? (t) => chat.switchBranch(t) : undefined,107              }} />;108            })}109            {chat.warning && <div className="flex items-center gap-2 rounded-xl border border-warn/30 bg-warn/10 px-3 py-2 text-[13px] text-warn"><AlertTriangle className="h-4 w-4" /> {chat.warning}</div>}110            <div className="h-2" />111          </div>112          {!stuck && <button onClick={() => { setStuck(true); scroller.current?.scrollTo({ top: scroller.current.scrollHeight, behavior: "smooth" }); }} className="sticky bottom-3 left-1/2 -translate-x-1/2 inline-flex h-8 w-8 items-center justify-center rounded-full border border-border glass shadow-soft text-fg-2 hover:text-fg"><ArrowDown className="h-4 w-4" /></button>}113        </div>114        <div className="no-print"><Composer onSubmit={submit} onStop={chat.stop} streaming={chat.streaming} conversationId={chat.conversationId} projectId={effectiveProject} initialModel={chat.conversation?.model_id ?? project?.default_model ?? null} /></div>115      </div>116      {artifact && <div className="hidden w-[46%] min-w-[380px] max-w-[760px] lg:block"><ArtifactPanel artifact={artifact} onClose={() => setArtifact(null)} /></div>}117      {artifact && <div className="fixed inset-0 z-50 lg:hidden bg-bg"><ArtifactPanel artifact={artifact} onClose={() => setArtifact(null)} /></div>}118    </div>119  );120}121122export function usePersistModel() { return setPrefs; }123
Files · laptop-build-20261101
Menu

Theme