| "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> |
| ); |
| } |
|
|