why-agent / client /frontend /src /components /EvidencePanel.tsx
MapoTofu9's picture
deploy: HF Spaces
5d30bdc
Raw
History Blame Contribute Delete
10.2 kB
"use client";
import { useState } from "react";
import type { EvidenceItem } from "@/lib/types";
function ToolBadge({ name }: { name: string }) {
const map: Record<string, string> = {
run_sql: "bg-blue-900/60 text-blue-200 border-blue-700/60",
inspect_schema: "bg-violet-900/60 text-violet-200 border-violet-600/60",
compare_periods: "bg-emerald-900/60 text-emerald-200 border-emerald-700/60",
decompose_metric: "bg-orange-900/60 text-orange-200 border-orange-700/60",
};
return (
<span
className={`px-2 py-0.5 rounded border text-xs font-mono ${map[name] ?? "bg-surface text-violet-200 border-frame"}`}
>
{name}
</span>
);
}
function DurationBadge({ ms }: { ms: number | null }) {
if (ms === null) return null;
return (
<span className="text-xs font-mono text-violet-600 tabular-nums">
{ms < 1000 ? `${Math.round(ms)}ms` : `${(ms / 1000).toFixed(1)}s`}
</span>
);
}
function ArgValue({ label, value }: { label: string; value: unknown }) {
if (typeof value === "string") {
const isLong = value.length > 60 || value.includes("\n");
return (
<div>
<span className="text-violet-500 text-xs">{label}: </span>
{isLong ? (
<pre className="mt-1 text-xs bg-black/40 rounded p-2 text-violet-200 whitespace-pre-wrap break-words font-mono">
{value}
</pre>
) : (
<span className="text-xs text-white font-mono">{value}</span>
)}
</div>
);
}
if (value === null || value === undefined) return null;
return (
<div>
<span className="text-violet-500 text-xs">{label}: </span>
<span className="text-xs text-white font-mono">
{JSON.stringify(value)}
</span>
</div>
);
}
function OutputPreview({
toolName,
output,
}: {
toolName: string;
output: Record<string, unknown>;
}) {
if (output.error) return null;
if (toolName === "run_sql") {
const rows = output.rows as Record<string, unknown>[] | undefined;
const count = (output.row_count as number) ?? rows?.length ?? 0;
if (!rows || rows.length === 0)
return <p className="text-xs text-violet-400">{count} rows returned</p>;
const cols = Object.keys(rows[0]);
return (
<div>
<p className="text-xs text-violet-500 mb-1">
{count} row{count !== 1 ? "s" : ""}
{(output.truncated as boolean) ? " (truncated)" : ""}
</p>
<div className="overflow-x-auto">
<table className="text-xs w-full">
<thead>
<tr>
{cols.map((c) => (
<th
key={c}
className="text-left text-violet-500 pr-4 pb-1 font-normal"
>
{c}
</th>
))}
</tr>
</thead>
<tbody>
{rows.slice(0, 3).map((row, i) => (
<tr key={i}>
{cols.map((c) => (
<td key={c} className="text-white pr-4 py-0.5 font-mono">
{String(row[c] ?? "")}
</td>
))}
</tr>
))}
</tbody>
</table>
{count > 3 && (
<p className="text-xs text-violet-600 mt-1">
+{count - 3} more rows
</p>
)}
</div>
</div>
);
}
if (toolName === "compare_periods") {
const { before_value, after_value, abs_delta, pct_delta } =
output as Record<string, number | null>;
if (before_value === undefined) return null;
const trend = (abs_delta ?? 0) >= 0 ? "text-emerald-400" : "text-red-400";
return (
<div className="flex items-center gap-3 text-sm">
<span className="text-violet-300">{before_value?.toFixed(4)}</span>
<span className="text-violet-600"></span>
<span className="text-white font-semibold">
{after_value?.toFixed(4)}
</span>
<span className={`font-mono ${trend}`}>
{(abs_delta ?? 0) > 0 ? "+" : ""}
{abs_delta?.toFixed(4)}
</span>
<span className={`font-mono ${trend}`}>
(
{pct_delta !== null
? `${pct_delta > 0 ? "+" : ""}${pct_delta?.toFixed(1)}%`
: "n/a"}
)
</span>
</div>
);
}
if (toolName === "decompose_metric") {
const slices = output.slices as
| { slice_value: string; anomaly_score: number }[]
| undefined;
if (!slices || slices.length === 0) return null;
return (
<div className="space-y-1">
{slices.slice(0, 3).map((s, i) => (
<div key={i} className="flex items-center gap-2 text-sm">
<span className="text-violet-600 w-4 font-mono">{i + 1}.</span>
<span className="text-white font-mono">{s.slice_value}</span>
<span className="text-violet-500 text-xs">anomaly:</span>
<span
className={`font-mono text-xs ${s.anomaly_score > 0 ? "text-orange-300" : "text-blue-300"}`}
>
{s.anomaly_score > 0 ? "+" : ""}
{s.anomaly_score?.toFixed(2)}
</span>
</div>
))}
{slices.length > 3 && (
<p className="text-xs text-violet-600">+{slices.length - 3} more</p>
)}
</div>
);
}
if (toolName === "inspect_schema") {
const tables = output.tables as string[] | undefined;
const cols = output.columns as unknown[] | undefined;
if (tables)
return (
<p className="text-sm text-violet-200">
{tables.length} table{tables.length !== 1 ? "s" : ""}:{" "}
{tables.join(", ")}
</p>
);
if (cols)
return (
<p className="text-sm text-violet-200">
{cols.length} column{cols.length !== 1 ? "s" : ""} described
</p>
);
}
return null;
}
function EvidenceRow({
item,
index,
isLatest,
streaming,
}: {
item: EvidenceItem;
index: number;
isLatest: boolean;
streaming: boolean;
}) {
const hasError = Boolean(item.output?.error);
const [open, setOpen] = useState(false);
return (
<div
className={`border rounded-lg ${
isLatest && streaming
? "border-violet-500 bg-violet-950/20"
: "border-frame bg-surface"
}`}
>
<button
onClick={() => setOpen((o) => !o)}
className="w-full flex items-center gap-2 px-3 py-2.5 text-left"
>
<span className="text-xs font-mono text-violet-600 w-5">
{index + 1}
</span>
<ToolBadge name={item.tool_name} />
<span className="text-xs font-mono text-violet-500">{item.phase}</span>
<DurationBadge ms={item.duration_ms} />
{isLatest && streaming ? (
<span className="ml-auto w-2 h-2 rounded-full bg-violet-400 animate-pulse" />
) : (
<span
className="ml-auto text-sm"
role="img"
aria-label={hasError ? "Error" : "Success"}
>
{hasError ? "⚠️" : "✅"}
</span>
)}
<span className="text-violet-600 text-xs">{open ? "▲" : "▼"}</span>
</button>
{open && (
<div className="px-4 pb-4 space-y-3 border-t border-frame pt-3">
{item.reasoning && (
<div className="border-l-2 border-violet-500 bg-violet-950/30 rounded-r px-3 py-2">
<p className="text-xs font-semibold text-violet-300 mb-1">
Agent thought
</p>
<p className="text-sm text-violet-100 leading-relaxed">
{item.reasoning}
</p>
</div>
)}
<div>
<p className="text-xs font-mono text-violet-500 mb-1.5 uppercase tracking-wider">
Input
</p>
<div className="bg-black/30 rounded-lg p-3 space-y-1.5">
{Object.entries(item.args).map(([k, v]) => (
<ArgValue key={k} label={k} value={v} />
))}
</div>
</div>
{!hasError && (
<div>
<p className="text-xs font-mono text-violet-500 mb-1.5 uppercase tracking-wider">
Result
</p>
<div className="bg-black/30 rounded-lg p-3">
<OutputPreview toolName={item.tool_name} output={item.output} />
</div>
</div>
)}
{hasError && (
<div className="bg-red-950/30 rounded-lg p-3">
<p className="text-sm text-red-400 font-medium">
Error: {item.output.error}
</p>
{item.output.hint && (
<p className="text-xs text-violet-400 mt-1">
Hint: {item.output.hint}
</p>
)}
</div>
)}
</div>
)}
</div>
);
}
export default function EvidencePanel({
evidence,
streaming = false,
}: {
evidence: EvidenceItem[];
streaming?: boolean;
}) {
const [expanded, setExpanded] = useState(false);
if (evidence.length === 0) return null;
if (streaming) {
return (
<div className="space-y-2">
{evidence.map((e, i) => (
<EvidenceRow
key={e.index}
item={e}
index={i}
isLatest={i === evidence.length - 1}
streaming
/>
))}
</div>
);
}
return (
<div>
<button
onClick={() => setExpanded((e) => !e)}
className="flex items-center gap-2 text-sm text-violet-400 hover:text-violet-200 transition-colors mb-2"
>
<span>🔍</span>
<span>{evidence.length} tool calls</span>
<span className="text-violet-600 text-xs">{expanded ? "▲" : "▼"}</span>
</button>
{expanded && (
<div className="space-y-2">
{evidence.map((e, i) => (
<EvidenceRow
key={e.index}
item={e}
index={i}
isLatest={false}
streaming={false}
/>
))}
</div>
)}
</div>
);
}