SPB Git
11.5 KB · 101 lines tsx
1"use client";2import { memo, useState } from "react";3import { RefreshCw, Pencil, GitBranch, Volume2, ChevronLeft, ChevronRight, Brain, FileText, AlertTriangle, Sparkles, ChevronDown, LayoutPanelLeft, Square } from "lucide-react";4import type { MessageDTO, Part } from "@/lib/client/types";5import { Markdown } from "./markdown";6import { ToolCallCard } from "./tool-call";7import { cn, CopyButton, Badge } from "../ui";8import { fmtMs, fmtTokens, fmtUsd } from "@/lib/client/fetcher";9import { useModels, shortName } from "@/lib/client/use-models";10import { usePrefs } from "@/lib/client/prefs";11import type { ArtifactView } from "./artifact-panel";12import { typeFromLang } from "./artifact-panel";1314export interface MessageActions {15  onRegenerate?: (id: string, modelId?: string) => void; onEdit?: (id: string, text: string) => void; onBranch?: (id: string) => void; onApprove?: (messageId: string, approve: string[], deny: string[]) => void;16  onSwitchSibling?: (targetId: string) => void; onOpenArtifact?: (a: ArtifactView) => void; siblings?: { ids: string[]; index: number };17}1819export const Message = memo(function Message({ m, actions, streaming, conversationId }: { m: MessageDTO; actions: MessageActions; streaming: boolean; conversationId: string | null }) {20  const isUser = m.role === "user";21  const { byId } = useModels(); const prefs = usePrefs();22  const [editing, setEditing] = useState(false); const [draft, setDraft] = useState(m.text); const [speaking, setSpeaking] = useState<HTMLAudioElement | null>(null); const [showReason, setShowReason] = useState(false);23  const model = byId(m.model_id);24  const textParts = m.parts.filter(p => p.type === "text") as Extract<Part, { type: "text" }>[];25  const reasoning = m.parts.find(p => p.type === "reasoning") as Extract<Part, { type: "reasoning" }> | undefined;26  const fullText = textParts.map(p => p.text).join("\n");27  const pending = m.parts.filter(p => p.type === "tool_call" && p.status === "pending_approval") as Extract<Part, { type: "tool_call" }>[];2829  async function speak() {30    if (speaking) { speaking.pause(); setSpeaking(null); return; }31    const r = await fetch("/api/tts", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ text: fullText.slice(0, 4000) }) });32    if (!r.ok) return; const url = URL.createObjectURL(await r.blob()); const a = new Audio(url); a.onended = () => setSpeaking(null); setSpeaking(a); void a.play();33  }34  const openArtifact = (code: string, lang: string) => actions.onOpenArtifact?.({ title: `${lang} — ${new Date(m.created_at).toLocaleTimeString("fr-CA", { hour: "2-digit", minute: "2-digit" })}`, type: typeFromLang(lang), language: lang, content: code, conversationId });3536  if (isUser) {37    return (38      <div id={m.id} className="group flex flex-col items-end gap-1 anim-fade-up">39        {m.parts.some(p => p.type === "image" || p.type === "file") && <div className="flex max-w-[85%] flex-wrap justify-end gap-2">{m.parts.map((p, i) => p.type === "image" ? <a key={i} href={`/api/attachments/${p.attachmentId}`} target="_blank" rel="noreferrer"><img src={`/api/attachments/${p.attachmentId}`} alt={p.name ?? "image"} className="max-h-56 rounded-xl border border-border object-cover" /></a> : p.type === "file" ? <a key={i} href={`/api/attachments/${p.attachmentId}?download=1`} className="inline-flex items-center gap-2 rounded-xl border border-border bg-surface px-3 py-2 text-[13px] hover:bg-surface-2"><FileText className="h-4 w-4 text-accent-2" /><span className="max-w-56 truncate">{p.name}</span>{p.pages ? <span className="text-muted">{p.pages} p.</span> : null}</a> : null)}</div>}40        {editing ? (41          <form onSubmit={e => { e.preventDefault(); actions.onEdit?.(m.id, draft); setEditing(false); }} className="w-full max-w-[85%]"><textarea autoFocus value={draft} onChange={e => setDraft(e.target.value)} rows={Math.min(12, draft.split("\n").length + 1)} className="input text-[15px]" /><div className="mt-1.5 flex justify-end gap-1.5"><button type="button" onClick={() => setEditing(false)} className="rounded-lg px-3 py-1.5 text-[13px] text-fg-2 hover:bg-surface-2">Annuler</button><button type="submit" className="rounded-lg bg-accent px-3 py-1.5 text-[13px] font-medium text-accent-ink">Envoyer</button></div></form>42        ) : fullText && <div className="max-w-[85%] whitespace-pre-wrap rounded-2xl rounded-br-md bg-user px-4 py-2.5 text-[15px] leading-relaxed">{fullText}</div>}43        <div className="flex h-6 items-center gap-0.5 opacity-0 transition-opacity group-hover:opacity-100 focus-within:opacity-100">44          {actions.siblings && actions.siblings.ids.length > 1 && <SiblingNav s={actions.siblings} onSwitch={actions.onSwitchSibling} />}45          <CopyButton text={fullText} size="xs" />46          {actions.onEdit && !streaming && <Act icon={Pencil} label="Modifier" onClick={() => { setDraft(fullText); setEditing(true); }} />}47          {actions.onBranch && <Act icon={GitBranch} label="Brancher ici" onClick={() => actions.onBranch!(m.id)} />}48        </div>49      </div>50    );51  }5253  return (54    <div id={m.id} className="group anim-fade-up">55      <div className="mb-1.5 flex items-center gap-2 text-[12px] text-muted">56        <span className={cn("inline-flex h-5 w-5 items-center justify-center rounded-md", model?.provider === "anthropic" ? "bg-accent-2/15 text-accent-2" : "bg-accent/15 text-accent")}><Sparkles className="h-3 w-3" /></span>57        <span className="font-medium text-fg-2">{shortName(model, m.model_id)}</span>58        {m.routing?.mode && m.routing.mode !== "manual" && <Badge tone="amber" className="cursor-help" >{m.routing.mode}<span className="sr-only">: {m.routing.reason}</span></Badge>}59        {m.routing?.servedBy && m.routing.servedBy !== m.model_id && <Badge tone="warn">servi par {m.routing.servedBy}</Badge>}60        {m.status === "cancelled" && <Badge>interrompu</Badge>}61      </div>62      {m.routing?.mode && m.routing.mode !== "manual" && m.routing.reason && <div className="mb-2 text-[12px] text-muted">↳ {m.routing.reason}</div>}63      {reasoning && reasoning.text && prefs.showReasoning && (64        <div className="mb-2">65          <button onClick={() => setShowReason(s => !s)} className="inline-flex items-center gap-1.5 text-[12px] text-muted hover:text-fg"><Brain className="h-3.5 w-3.5" /> Raisonnement {m.status === "streaming" && !fullText ? "en cours…" : "résumé"} <ChevronDown className={cn("h-3 w-3 transition-transform", showReason && "rotate-180")} /></button>66          {(showReason || (m.status === "streaming" && !fullText)) && <div className="mt-1.5 max-h-64 overflow-y-auto rounded-xl border border-border bg-surface px-3.5 py-2.5 text-[13px] leading-relaxed text-fg-2 whitespace-pre-wrap">{reasoning.text}</div>}67        </div>68      )}69      {m.parts.map((p, i) => {70        if (p.type === "text") return <Markdown key={i} text={p.text} onPreview={openArtifact} />;71        if (p.type === "tool_call") return <ToolCallCard key={p.id} part={p} onApprove={pending.length && actions.onApprove ? ok => actions.onApprove!(m.id, ok ? [p.id] : [], ok ? [] : [p.id]) : undefined} />;72        if (p.type === "generated_image") return <figure key={i} className="my-3"><a href={`/api/attachments/${p.attachmentId}`} target="_blank" rel="noreferrer"><img src={`/api/attachments/${p.attachmentId}`} alt={p.prompt} className="max-h-[480px] rounded-xl border border-border" /></a><figcaption className="mt-1 text-[12px] text-muted">{p.model} · {p.prompt.slice(0, 140)}</figcaption></figure>;73        if (p.type === "artifact") return <button key={i} onClick={async () => { const r = await fetch(`/api/artifacts/${p.artifactId}`); const j = await r.json(); actions.onOpenArtifact?.({ id: p.artifactId, title: p.title, type: p.artifactType, language: p.language, content: j.artifact.content, conversationId }); }} className="my-2 flex w-full max-w-md items-center gap-3 rounded-xl border border-border bg-surface px-3 py-2.5 text-left hover:border-border-strong hover:bg-surface-2"><LayoutPanelLeft className="h-5 w-5 text-accent" /><span className="min-w-0 flex-1"><span className="block truncate text-[13px] font-medium">{p.title}</span><span className="block text-[11px] text-muted">Artefact {p.artifactType}{p.language ? ` · ${p.language}` : ""} — cliquer pour l'aperçu</span></span></button>;74        if (p.type === "citation") return null;75        return null;76      })}77      {m.parts.some(p => p.type === "citation") && <div className="mt-3 flex flex-wrap gap-1.5">{m.parts.filter(p => p.type === "citation").map((p, i) => p.type === "citation" ? <a key={i} href={p.url} target="_blank" rel="noopener noreferrer" className="max-w-60 truncate rounded-md border border-border bg-surface px-2 py-0.5 text-[11px] text-fg-2 hover:text-accent">{p.title ?? p.url}</a> : null)}</div>}78      {m.status === "streaming" && !fullText && !reasoning?.text && <div className="flex items-center gap-1 py-2"><span className="typing-dot" /><span className="typing-dot" /><span className="typing-dot" /></div>}79      {m.error && <div className="mt-2 flex items-start gap-2 rounded-xl border border-danger/30 bg-danger/10 px-3 py-2 text-[13px] text-danger"><AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" /><span>{m.error}</span></div>}80      {m.status !== "streaming" && (81        <div className="mt-1.5 flex flex-wrap items-center gap-0.5 text-[12px] text-muted opacity-0 transition-opacity group-hover:opacity-100 focus-within:opacity-100 md:opacity-100">82          {actions.siblings && actions.siblings.ids.length > 1 && <SiblingNav s={actions.siblings} onSwitch={actions.onSwitchSibling} />}83          <CopyButton text={fullText} size="xs" />84          {actions.onRegenerate && !streaming && <Act icon={RefreshCw} label="Régénérer" onClick={() => actions.onRegenerate!(m.id)} />}85          {actions.onBranch && <Act icon={GitBranch} label="Brancher" onClick={() => actions.onBranch!(m.id)} />}86          {fullText && <Act icon={speaking ? Square : Volume2} label={speaking ? "Arrêter" : "Écouter"} onClick={speak} />}87          {m.usage && <span className="ml-auto flex flex-wrap items-center gap-x-2 text-[11px] tabular-nums" title={`entrée ${m.usage.input} · sortie ${m.usage.output}${m.usage.cached ? ` · cache ${m.usage.cached}` : ""}${m.usage.reasoning ? ` · raisonnement ${m.usage.reasoning}` : ""}`}>88            <span>{fmtTokens(m.usage.input)} → {fmtTokens(m.usage.output)}</span><span>{fmtUsd(m.cost_usd)}</span><span>{fmtMs(m.latency_ms)}{m.ttft_ms != null ? ` · 1er jeton ${fmtMs(m.ttft_ms)}` : ""}</span></span>}89        </div>90      )}91    </div>92  );93});9495function Act({ icon: Icon, label, onClick }: { icon: React.ComponentType<{ className?: string }>; label: string; onClick: () => void }) {96  return <button type="button" onClick={onClick} title={label} className="inline-flex items-center gap-1 rounded-md px-1 py-0.5 text-[11px] text-fg-2 hover:bg-surface-2 hover:text-fg"><Icon className="h-3.5 w-3.5" /><span className="hidden sm:inline">{label}</span></button>;97}98function SiblingNav({ s, onSwitch }: { s: { ids: string[]; index: number }; onSwitch?: (id: string) => void }) {99  return <span className="mr-1 inline-flex items-center rounded-md border border-border text-[11px]"><button disabled={s.index <= 0} onClick={() => onSwitch?.(s.ids[s.index - 1])} className="px-1 py-0.5 disabled:opacity-30 hover:bg-surface-2"><ChevronLeft className="h-3 w-3" /></button><span className="px-1 tabular-nums">{s.index + 1}/{s.ids.length}</span><button disabled={s.index >= s.ids.length - 1} onClick={() => onSwitch?.(s.ids[s.index + 1])} className="px-1 py-0.5 disabled:opacity-30 hover:bg-surface-2"><ChevronRight className="h-3 w-3" /></button></span>;100}101
Files · 4de79146590fcddf45fe93dc89560ed6406cf783
Menu

Theme