Spaces:
Running
Running
| import React, { useEffect, useRef, useState } from "react"; | |
| import { http } from "../lib/api"; | |
| import { X, Play, Trash2, Bug, Activity, Check, AlertCircle, Loader2, ChevronRight, Filter } from "lucide-react"; | |
| const TABS = [ | |
| { id: "events", label: "Événements", icon: Activity }, | |
| { id: "selftest", label: "Auto-test", icon: Bug }, | |
| ]; | |
| const FILTER_OPTS = [ | |
| { id: "all", label: "Tout" }, | |
| { id: "tool", label: "Tools" }, | |
| { id: "delta", label: "Deltas" }, | |
| { id: "error", label: "Erreurs" }, | |
| ]; | |
| export default function DebugWindow({ events, onClose, onClearEvents }) { | |
| const [tab, setTab] = useState("events"); | |
| const [filter, setFilter] = useState("all"); | |
| const [running, setRunning] = useState(false); | |
| const [results, setResults] = useState(null); | |
| const scrollRef = useRef(null); | |
| useEffect(() => { | |
| if (scrollRef.current) scrollRef.current.scrollTop = scrollRef.current.scrollHeight; | |
| }, [events]); | |
| const runSelftest = async () => { | |
| setRunning(true); | |
| setResults(null); | |
| try { | |
| const r = await http.post("/agent/selftest"); | |
| setResults(r.data); | |
| } catch (e) { | |
| setResults({ ok: false, error: e?.response?.data?.detail || e.message }); | |
| } finally { | |
| setRunning(false); | |
| } | |
| }; | |
| const filteredEvents = events.filter((e) => { | |
| if (filter === "all") return true; | |
| if (filter === "tool") return e.type?.startsWith("tool_"); | |
| if (filter === "delta") return e.type === "delta"; | |
| if (filter === "error") return e.type === "error"; | |
| return true; | |
| }); | |
| return ( | |
| <div | |
| data-testid="debug-window" | |
| className="fixed top-20 right-6 z-50 w-[540px] h-[620px] flex flex-col rounded-2xl overflow-hidden" | |
| style={{ | |
| background: "var(--emo-drawer-bg)", | |
| backdropFilter: "blur(28px)", | |
| border: "1px solid rgba(6,182,212,0.18)", | |
| boxShadow: "0 24px 80px rgba(0,0,0,0.35), 0 0 40px rgba(6,182,212,0.1)", | |
| animation: "fadeIn 0.2s ease", | |
| }} | |
| > | |
| {/* Header */} | |
| <div | |
| className="flex items-center justify-between px-4 py-3 em-border-b" | |
| style={{ background: "rgba(6,182,212,0.04)" }} | |
| > | |
| <div className="flex items-center gap-2.5"> | |
| <div className="relative"> | |
| <Bug size={15} style={{ color: "#06B6D4", filter: "drop-shadow(0 0 6px rgba(6,182,212,0.5))" }} /> | |
| <span className="absolute -top-0.5 -right-0.5 w-1.5 h-1.5 rounded-full bg-cyan-400 animate-pulse" /> | |
| </div> | |
| <span className="font-heading text-sm font-medium">Console debug</span> | |
| <span className="text-[10px] uppercase tracking-[0.18em] text-muted-em">temps réel</span> | |
| </div> | |
| <button onClick={onClose} className="p-1.5 rounded em-hover text-muted-em" data-testid="debug-close-btn"> | |
| <X size={14} /> | |
| </button> | |
| </div> | |
| {/* Tabs */} | |
| <div className="flex items-center gap-1 px-3 pt-2.5 em-border-b"> | |
| {TABS.map((t) => { | |
| const Icon = t.icon; | |
| const active = t.id === tab; | |
| return ( | |
| <button | |
| key={t.id} | |
| data-testid={`debug-tab-${t.id}`} | |
| onClick={() => setTab(t.id)} | |
| className="flex items-center gap-1.5 px-3 py-2 rounded-t-lg text-xs transition relative" | |
| style={{ | |
| background: active ? "rgba(6,182,212,0.08)" : "transparent", | |
| color: active ? "var(--emo-text)" : "var(--emo-text-muted)", | |
| }} | |
| > | |
| <Icon size={12} style={{ color: active ? "#06B6D4" : "currentColor" }} /> | |
| {t.label} | |
| {active && ( | |
| <span className="absolute bottom-0 left-3 right-3 h-px" style={{ background: "#06B6D4" }} /> | |
| )} | |
| </button> | |
| ); | |
| })} | |
| </div> | |
| {/* Body */} | |
| {tab === "events" && ( | |
| <> | |
| <div className="flex items-center justify-between px-4 py-2 text-[11px] em-border-b"> | |
| <div className="flex items-center gap-2"> | |
| <Filter size={11} className="text-muted-em" /> | |
| {FILTER_OPTS.map((f) => ( | |
| <button | |
| key={f.id} | |
| data-testid={`debug-filter-${f.id}`} | |
| onClick={() => setFilter(f.id)} | |
| className="px-2 py-0.5 rounded text-[10px] transition" | |
| style={{ | |
| background: filter === f.id ? "var(--emo-tab-active-bg)" : "transparent", | |
| color: filter === f.id ? "var(--emo-text)" : "var(--emo-text-muted)", | |
| }} | |
| > | |
| {f.label} | |
| </button> | |
| ))} | |
| <span className="ml-2 text-[10px] text-muted-em">{filteredEvents.length}/{events.length}</span> | |
| </div> | |
| <button | |
| data-testid="debug-clear-events" | |
| onClick={onClearEvents} | |
| className="flex items-center gap-1 text-muted-em hover:text-[var(--emo-text)] text-[10px]" | |
| > | |
| <Trash2 size={10} /> Vider | |
| </button> | |
| </div> | |
| <div ref={scrollRef} className="flex-1 overflow-y-auto scrollbar-thin py-1" data-testid="debug-events"> | |
| {filteredEvents.length === 0 && ( | |
| <div className="h-full flex flex-col items-center justify-center text-center px-8"> | |
| <Activity size={28} className="text-muted-em mb-3 opacity-30" /> | |
| <p className="text-xs text-muted-em">En attente d'évènements…</p> | |
| <p className="text-[10px] text-muted-em mt-1 opacity-70">Lance une conversation pour voir les SSE / tool calls en live.</p> | |
| </div> | |
| )} | |
| {filteredEvents.map((e, i) => ( | |
| <EventLine key={i} evt={e} /> | |
| ))} | |
| </div> | |
| </> | |
| )} | |
| {tab === "selftest" && ( | |
| <div className="flex-1 overflow-y-auto scrollbar-thin p-5 space-y-4"> | |
| <button | |
| data-testid="selftest-run-btn" | |
| onClick={runSelftest} | |
| disabled={running} | |
| className="w-full py-3 rounded-2xl text-sm font-medium flex items-center justify-center gap-2 disabled:opacity-50 transition-all" | |
| style={{ | |
| background: "linear-gradient(135deg, #06B6D4 0%, #0891B2 100%)", | |
| color: "#021824", | |
| boxShadow: "0 0 24px rgba(6,182,212,0.35), inset 0 1px 0 rgba(255,255,255,0.2)", | |
| }} | |
| > | |
| {running ? <><Loader2 size={14} className="animate-spin" /> En cours…</> : <><Play size={14} /> Diagnostic</>} | |
| </button> | |
| {results && ( | |
| <div data-testid="selftest-results" className="space-y-2 mt-1"> | |
| {results.error && ( | |
| <div className="text-xs p-3 rounded-xl flex items-start gap-2 emo-alert-error"> | |
| <AlertCircle size={14} className="mt-0.5 flex-shrink-0" /> {results.error} | |
| </div> | |
| )} | |
| {results.steps?.map((s, i) => ( | |
| <StepCard key={s.step} step={s} index={i + 1} /> | |
| ))} | |
| {results.steps && ( | |
| <div | |
| className={`mt-3 p-3 rounded-xl text-xs text-center font-medium ${results.ok ? "emo-alert-success" : "emo-alert-error"}`} | |
| > | |
| {results.ok | |
| ? "✓ Tous les tests passent — agent opérationnel." | |
| : "✗ Au moins un test échoue — vérifie l'agent local."} | |
| </div> | |
| )} | |
| </div> | |
| )} | |
| </div> | |
| )} | |
| </div> | |
| ); | |
| } | |
| const StepCard = ({ step, index }) => { | |
| const [open, setOpen] = useState(false); | |
| return ( | |
| <div | |
| className={`rounded-xl overflow-hidden transition ${step.ok ? "emo-alert-success" : "emo-alert-error"}`} | |
| > | |
| <button onClick={() => setOpen(!open)} className="w-full flex items-center gap-3 px-3 py-2.5 text-left"> | |
| <div className="flex items-center justify-center w-6 h-6 rounded-full text-[10px] font-medium" style={{ | |
| background: step.ok ? "var(--emo-success-bg)" : "var(--emo-error-bg)", | |
| color: step.ok ? "var(--emo-success-text)" : "var(--emo-error-text)", | |
| }}> | |
| {step.ok ? <Check size={11} /> : <AlertCircle size={11} />} | |
| </div> | |
| <div className="flex-1 min-w-0"> | |
| <p className="text-[12px] font-medium" style={{ color: step.ok ? "var(--emo-success-text)" : "var(--emo-error-text)" }}> | |
| {index}. {step.label} | |
| </p> | |
| <code className="text-[10px] text-muted-em font-code">{step.step}</code> | |
| </div> | |
| <ChevronRight size={12} className="opacity-40 transition" style={{ transform: open ? "rotate(90deg)" : "none" }} /> | |
| </button> | |
| {open && ( | |
| <pre className="px-3 pb-3 font-code text-[10px] overflow-x-auto" style={{ color: "var(--emo-code-text)" }}> | |
| {JSON.stringify(step.result, null, 2)} | |
| </pre> | |
| )} | |
| </div> | |
| ); | |
| }; | |
| const EventLine = ({ evt }) => { | |
| const meta = TYPE_META[evt.type] || { color: "#6D5F82", icon: "·" }; | |
| return ( | |
| <div className="flex items-start gap-2 px-3 py-1 text-[11px] em-hover-subtle font-code"> | |
| <span className="text-[9px] text-muted-em w-14 flex-shrink-0 mt-0.5">{evt._t || "--:--:--"}</span> | |
| <span | |
| className="w-3 flex-shrink-0 text-center font-bold" | |
| style={{ color: meta.color }} | |
| > | |
| {meta.icon} | |
| </span> | |
| <span className="w-20 flex-shrink-0 font-medium" style={{ color: meta.color }}>{evt.type}</span> | |
| <span className="flex-1 text-secondary-em break-all leading-relaxed"> | |
| {compact(evt)} | |
| </span> | |
| </div> | |
| ); | |
| }; | |
| const TYPE_META = { | |
| delta: { color: "#A89BBD", icon: "·" }, | |
| tool_start: { color: "#06B6D4", icon: "▸" }, | |
| tool_executing: { color: "#06B6D4", icon: "▶" }, | |
| tool_result: { color: "#34D399", icon: "✓" }, | |
| done: { color: "#A855F7", icon: "■" }, | |
| error: { color: "#EF4444", icon: "✗" }, | |
| }; | |
| function compact(evt) { | |
| const { type, _t, ...rest } = evt; | |
| if (type === "delta") return `"${(rest.content || "").slice(0, 100)}"`; | |
| if (type === "tool_start") return `${rest.name} #${rest.id?.slice(-6)}`; | |
| if (type === "tool_executing") return `${rest.name} ${JSON.stringify(rest.arguments).slice(0, 100)}`; | |
| if (type === "tool_result") return `${rest.name} → ${rest.result?.ok === false ? "ERR: " + (rest.result?.error || "").slice(0, 60) : "ok"}`; | |
| if (type === "done") return `mood=${rest.mood} verified=${rest.verified || "-"}`; | |
| if (type === "error") return rest.content; | |
| return JSON.stringify(rest).slice(0, 100); | |
| } | |