SPB Git

Repository Public MIT

agent-activity.tsx 10.3 KB 200 lines tsx

1import { useEffect, useMemo, useState } from "react";2import { AnimatePresence, motion } from "framer-motion";3import { Brain, Check, ChevronDown, CircleAlert, ExternalLink, Loader2, Search, Sparkles } from "lucide-react";4import type { AgentStep } from "./agent-steps";5import type { SearchQuery } from "@/hooks/useChatSession";6import { summarizeToolInput, toolMeta } from "@/lib/tools-meta";7import { fmtDuration, hostnameOf } from "@/lib/format";8import { cn } from "@/lib/utils";910interface AgentActivityProps {11  steps: AgentStep[];12  thinking?: string;13  searchQueries?: SearchQuery[];14  statusMessage?: string;15  currentSearchIndex?: number;16  isStreaming: boolean;17  hasAnswer: boolean;18}1920/**21 * Panneau « Travail de l'agent » : raisonnement, recherches web et outils appelés,22 * en direct pendant la génération, replié en résumé une fois la réponse terminée.23 */24export function AgentActivity({ steps, thinking = "", searchQueries = [], statusMessage = "", currentSearchIndex = 0, isStreaming, hasAnswer }: AgentActivityProps) {25  const [open, setOpen] = useState(true);26  const [thinkingOpen, setThinkingOpen] = useState(false);2728  // Ouvert pendant le travail, replié dès que la réponse commence à s'écrire.29  useEffect(() => {30    if (isStreaming && !hasAnswer) setOpen(true);31    if (hasAnswer) setOpen(false);32  }, [isStreaming, hasAnswer]);3334  const running = steps.filter((s) => s.status === "running").length;35  const errors = steps.filter((s) => s.status === "error").length;36  const done = steps.filter((s) => s.status === "completed").length;37  const families = useMemo(() => {38    const set = new Map<string, { label: string; dot: string; n: number }>();39    for (const s of steps) {40      const m = toolMeta(s.tool);41      const cur = set.get(m.family);42      set.set(m.family, { label: m.label, dot: m.dot, n: (cur?.n ?? 0) + 1 });43    }44    return Array.from(set.values());45  }, [steps]);4647  if (steps.length === 0 && !thinking && searchQueries.length === 0 && !statusMessage) return null;4849  const title = isStreaming && !hasAnswer50    ? (running > 0 ? "L'agent interroge les données…" : thinking && steps.length === 0 ? "L'agent réfléchit…" : "Analyse en cours…")51    : `Travail de l'agent`;5253  const summary = [54    steps.length > 0 && `${steps.length} outil${steps.length > 1 ? "s" : ""}`,55    searchQueries.length > 0 && `${searchQueries.length} recherche${searchQueries.length > 1 ? "s" : ""}`,56    thinking && "raisonnement",57    errors > 0 && `${errors} erreur${errors > 1 ? "s" : ""}`,58  ].filter(Boolean).join(" · ");5960  return (61    <motion.section62      initial={{ opacity: 0, y: 6 }}63      animate={{ opacity: 1, y: 0 }}64      className="surface overflow-hidden"65      data-testid="agent-activity"66      aria-live="polite"67    >68      <button69        type="button"70        onClick={() => setOpen((o) => !o)}71        className="flex w-full items-center gap-3 px-4 py-3 text-left hover:bg-accent/50 transition-colors"72        aria-expanded={open}73      >74        <span className={cn("grid h-8 w-8 shrink-0 place-items-center rounded-xl", isStreaming && !hasAnswer ? "bg-brand text-white animate-pulse-ring" : "bg-brand-soft text-primary")}>75          {isStreaming && !hasAnswer ? <Loader2 className="h-4 w-4 animate-spin" /> : <Sparkles className="h-4 w-4" />}76        </span>77        <span className="min-w-0 flex-1">78          <span className="block text-sm font-semibold leading-tight">{title}</span>79          <span className="block truncate text-[12px] text-muted-foreground">80            {isStreaming && !hasAnswer && statusMessage ? statusMessage : summary || "Détails de l'exécution"}81          </span>82        </span>83        {!open && steps.length > 0 && (84          <span className="hidden sm:flex items-center -space-x-1">85            {families.slice(0, 6).map((f) => (86              <span key={f.label} className={cn("h-2.5 w-2.5 rounded-full ring-2 ring-card", f.dot)} title={f.label} />87            ))}88          </span>89        )}90        <span className="ml-1 hidden sm:inline-flex items-center gap-1 text-[12px] tnum text-muted-foreground">91          {done > 0 && <><Check className="h-3.5 w-3.5 text-success" /> {done}</>}92          {errors > 0 && <><CircleAlert className="h-3.5 w-3.5 text-destructive ml-1.5" /> {errors}</>}93        </span>94        <ChevronDown className={cn("h-4 w-4 text-muted-foreground transition-transform", open && "rotate-180")} />95      </button>9697      <AnimatePresence initial={false}>98        {open && (99          <motion.div100            key="body"101            initial={{ height: 0, opacity: 0 }}102            animate={{ height: "auto", opacity: 1 }}103            exit={{ height: 0, opacity: 0 }}104            transition={{ duration: 0.22, ease: [0.2, 0.7, 0.2, 1] }}105            className="overflow-hidden"106          >107            <div className="border-t border-border px-4 py-3 space-y-3">108              {/* Raisonnement */}109              {thinking && (110                <div className="rounded-xl border border-violet-500/20 bg-violet-500/5">111                  <button type="button" onClick={() => setThinkingOpen((o) => !o)} className="flex w-full items-center gap-2 px-3 py-2 text-left" aria-expanded={thinkingOpen}>112                    <Brain className={cn("h-4 w-4 text-violet-500", isStreaming && !hasAnswer && "animate-pulse")} />113                    <span className="text-[13px] font-medium">Raisonnement</span>114                    <span className="ml-auto text-[11px] text-muted-foreground tnum">{Math.round(thinking.length / 5)} mots</span>115                    <ChevronDown className={cn("h-3.5 w-3.5 text-muted-foreground transition-transform", thinkingOpen && "rotate-180")} />116                  </button>117                  {thinkingOpen && (118                    <pre className="max-h-64 overflow-y-auto whitespace-pre-wrap break-words border-t border-violet-500/15 px-3 py-2.5 font-sans text-[12.5px] leading-relaxed text-muted-foreground">119                      {thinking}120                    </pre>121                  )}122                </div>123              )}124125              {/* Recherches web */}126              {searchQueries.length > 0 && (127                <ol className="space-y-1.5" data-testid="search-queries-section">128                  {searchQueries.map((q, i) => {129                    const done = !!q.results;130                    const active = !done && currentSearchIndex === i + 1;131                    return (132                      <li key={i} className="flex items-start gap-2.5 rounded-lg px-2 py-1.5" data-testid={`search-query-${i}`}>133                        <span className={cn("mt-0.5 grid h-5 w-5 shrink-0 place-items-center rounded-full", done ? "bg-success/15 text-success" : active ? "bg-primary/15 text-primary" : "bg-muted text-muted-foreground")}>134                          {done ? <Check className="h-3 w-3" /> : active ? <Loader2 className="h-3 w-3 animate-spin" /> : <Search className="h-3 w-3" />}135                        </span>136                        <span className="min-w-0 flex-1">137                          <span className="block text-[13px] leading-snug">{q.query}</span>138                          {q.results && q.results.length > 0 && (139                            <span className="mt-1 flex flex-wrap gap-1">140                              {q.results.slice(0, 4).map((r, j) => (141                                <a key={j} href={r.url} target="_blank" rel="noopener noreferrer" className="inline-flex items-center gap-1 rounded-full border border-border bg-background px-2 py-0.5 text-[11px] text-muted-foreground hover:text-primary hover:border-primary/40" data-testid={`search-result-link-${i}-${j}`}>142                                  <ExternalLink className="h-2.5 w-2.5" />143                                  {hostnameOf(r.url)}144                                </a>145                              ))}146                            </span>147                          )}148                        </span>149                      </li>150                    );151                  })}152                </ol>153              )}154155              {/* Outils */}156              {steps.length > 0 && (157                <ol className="space-y-1">158                  {steps.map((s, i) => {159                    const m = toolMeta(s.tool);160                    const Icon = m.icon;161                    const input = summarizeToolInput(s.input);162                    return (163                      <li key={`${s.tool}-${i}`} className="flex items-center gap-2.5 rounded-lg px-2 py-1.5 hover:bg-accent/40">164                        <span className={cn("grid h-6 w-6 shrink-0 place-items-center rounded-md border text-[11px]", m.chip)}>165                          <Icon className="h-3.5 w-3.5" />166                        </span>167                        <span className="min-w-0 flex-1 flex items-baseline gap-2">168                          <span className="text-[13px] font-medium leading-tight">{m.label}</span>169                          {input && <span className="truncate font-mono text-[11px] text-muted-foreground">{input}</span>}170                        </span>171                        <span className="shrink-0 inline-flex items-center gap-1.5 text-[11px] tnum text-muted-foreground">172                          {s.status === "completed" && s.duration != null && <span>{fmtDuration(s.duration)}</span>}173                          {s.status === "running" && <Loader2 className="h-3.5 w-3.5 animate-spin text-primary" />}174                          {s.status === "completed" && <Check className="h-3.5 w-3.5 text-success" />}175                          {s.status === "error" && (176                            <span className="inline-flex items-center gap-1 text-destructive" title={s.error}>177                              <CircleAlert className="h-3.5 w-3.5" /> échec178                            </span>179                          )}180                          {s.status === "pending" && <span className="h-1.5 w-1.5 rounded-full bg-muted-foreground/50" />}181                        </span>182                      </li>183                    );184                  })}185                </ol>186              )}187188              {statusMessage && isStreaming && (189                <p className="flex items-center gap-2 text-[12px] text-muted-foreground" data-testid="status-message">190                  <Loader2 className="h-3 w-3 animate-spin" /> {statusMessage}191                </p>192              )}193            </div>194          </motion.div>195        )}196      </AnimatePresence>197    </motion.section>198  );199}200
Files main
Menu

Theme