SPB Git
5.9 KB · 51 lines tsx
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
Files · laptop-build-20261101
Menu

Theme