spb/chat-spboucher-ai
Public
1"use client";2import { useState } from "react";3import { ChevronRight, Globe, Code2, FileSearch, Image as ImageIcon, Brain, Loader2, CheckCircle2, XCircle, ShieldAlert, Wrench } from "lucide-react";4import type { Part } from "@/lib/client/types";5import { cn, Button } from "../ui";67const ICONS: Record<string, React.ComponentType<{ className?: string }>> = { web_search: Globe, fetch_page: Globe, python: Code2, search_documents: FileSearch, generate_image: ImageIcon, save_memory: Brain };8const LABELS: Record<string, (i: Record<string, unknown>) => string> = {9 web_search: i => `Recherche web : « ${i.query} »`, fetch_page: i => `Lecture de ${String(i.url).replace(/^https?:\/\//, "").slice(0, 60)}`, python: () => "Exécution Python", search_documents: i => `Recherche dans les documents : « ${i.query} »`, generate_image: i => `Génération d'image : « ${String(i.prompt).slice(0, 60)} »`, save_memory: i => `Mémoire proposée : « ${String(i.content).slice(0, 60)} »`,10};1112export function ToolCallCard({ part, onApprove }: { part: Extract<Part, { type: "tool_call" }>; onApprove?: (approve: boolean) => void }) {13 const [open, setOpen] = useState(false);14 const Icon = ICONS[part.name] ?? Wrench;15 const input = (part.input ?? {}) as Record<string, unknown>;16 const label = LABELS[part.name]?.(input) ?? part.name;17 const out = part.output as Record<string, unknown> | string | undefined;18 return (19 <div className={cn("my-2 rounded-xl border text-[13px]", part.status === "error" ? "border-danger/30 bg-danger/5" : part.status === "pending_approval" ? "border-warn/40 bg-warn/5" : "border-border bg-surface")}>20 <button type="button" onClick={() => setOpen(o => !o)} className="flex w-full items-center gap-2 px-3 py-2 text-left">21 <Icon className="h-4 w-4 shrink-0 text-accent" />22 <span className="min-w-0 flex-1 truncate text-fg-2">{label}</span>23 {part.status === "running" && <Loader2 className="h-3.5 w-3.5 animate-spin text-muted" />}24 {part.status === "ok" && <CheckCircle2 className="h-3.5 w-3.5 text-ok" />}25 {part.status === "error" && <XCircle className="h-3.5 w-3.5 text-danger" />}26 {part.status === "denied" && <XCircle className="h-3.5 w-3.5 text-muted" />}27 {part.status === "pending_approval" && <ShieldAlert className="h-3.5 w-3.5 text-warn" />}28 {part.durationMs != null && <span className="text-[11px] text-muted">{(part.durationMs / 1000).toFixed(1)} s</span>}29 <ChevronRight className={cn("h-3.5 w-3.5 text-muted transition-transform", open && "rotate-90")} />30 </button>31 {part.status === "pending_approval" && onApprove && <div className="flex items-center gap-2 border-t border-border px-3 py-2"><span className="flex-1 text-[12px] text-fg-2">Cet outil requiert ton approbation.</span><Button size="sm" variant="primary" onClick={() => onApprove(true)}>Autoriser</Button><Button size="sm" variant="ghost" onClick={() => onApprove(false)}>Refuser</Button></div>}32 {open && (33 <div className="border-t border-border px-3 py-2 space-y-2">34 <div><div className="mb-1 text-[11px] uppercase tracking-wider text-muted">Entrée</div>{part.name === "python" ? <pre className="max-h-64 overflow-auto rounded-lg bg-[#0d1117] p-2 text-[12px] text-fg-2"><code>{String(input.code ?? "")}</code></pre> : <pre className="max-h-40 overflow-auto rounded-lg bg-surface-2 p-2 text-[12px] text-fg-2">{JSON.stringify(input, null, 2)}</pre>}</div>35 {part.error && <div className="rounded-lg bg-danger/10 p-2 text-[12px] text-danger">{part.error}</div>}36 {out !== undefined && <div><div className="mb-1 text-[11px] uppercase tracking-wider text-muted">Résultat</div><ToolOutput name={part.name} out={out} /></div>}37 </div>38 )}39 </div>40 );41}4243function ToolOutput({ name, out }: { name: string; out: Record<string, unknown> | string }) {44 if (typeof out === "string") return <pre className="max-h-64 overflow-auto rounded-lg bg-surface-2 p-2 text-[12px]">{out}</pre>;45 if (name === "web_search" && Array.isArray(out.results)) return <ul className="space-y-1.5">{(out.results as { title: string; url: string; snippet: string }[]).map((r, i) => <li key={i} className="rounded-lg bg-surface-2 p-2"><a href={r.url} target="_blank" rel="noopener noreferrer" className="font-medium text-accent hover:underline">{r.title}</a><div className="truncate text-[11px] text-muted">{r.url}</div><div className="mt-0.5 text-[12px] text-fg-2 truncate-2">{r.snippet}</div></li>)}</ul>;46 if (name === "python") return <div className="space-y-1">{!!out.stdout && <pre className="max-h-64 overflow-auto rounded-lg bg-[#0d1117] p-2 text-[12px]">{String(out.stdout)}</pre>}{!!out.stderr && <pre className="max-h-40 overflow-auto rounded-lg bg-danger/10 p-2 text-[12px] text-danger">{String(out.stderr)}</pre>}<div className="text-[11px] text-muted">code de sortie {String(out.exitCode)} · {String(out.durationMs)} ms{out.timedOut ? " · délai dépassé" : ""}</div></div>;47 if (name === "search_documents" && Array.isArray(out.passages)) return <ul className="space-y-1.5">{(out.passages as { file: string; page: number | null; score: number; text: string }[]).map((p, i) => <li key={i} className="rounded-lg bg-surface-2 p-2"><div className="text-[11px] text-muted">{p.file}{p.page ? ` · p. ${p.page}` : ""} · score {p.score}</div><div className="mt-0.5 text-[12px] text-fg-2 truncate-2">{p.text}</div></li>)}</ul>;48 if (name === "fetch_page" && typeof out.text === "string") return <div className="rounded-lg bg-surface-2 p-2 text-[12px] text-fg-2"><div className="mb-1 truncate text-[11px] text-muted">{String(out.title ?? out.url)}</div><div className="max-h-40 overflow-auto whitespace-pre-wrap">{(out.text as string).slice(0, 3000)}{(out.text as string).length > 3000 ? "…" : ""}</div></div>;49 return <pre className="max-h-64 overflow-auto rounded-lg bg-surface-2 p-2 text-[12px] text-fg-2">{JSON.stringify(out, null, 2).slice(0, 6000)}</pre>;50}51