agentic-rag / frontend /src /components /ResponseDisplay.tsx
vksepm
fixed plotly visualization and added evals
73af3c0
Raw
History Blame Contribute Delete
9.67 kB
import type { ReactNode } from "react";
import type { EvalScores, EvalState, QueryResponse } from "../types";
import CitationList from "./CitationList";
import PlotlyChart from "./PlotlyChart";
interface Props {
response: QueryResponse;
showInlineChart?: boolean;
evalState?: EvalState;
}
const FULL_LABELS: Record<string, string> = {
Ctx: "Context Relevance",
Grd: "Groundedness",
Ans: "Answer Relevance",
};
function ScoreBadge({ label, score }: { label: string; score: number | null }) {
if (score === null) return null;
const pct = Math.round(score * 100);
const colorClass =
score >= 0.85 ? "text-emerald-600 bg-emerald-50 border-emerald-200"
: score >= 0.5 ? "text-amber-600 bg-amber-50 border-amber-200"
: "text-red-600 bg-red-50 border-red-200";
return (
<span
title={`${FULL_LABELS[label] ?? label}: ${score.toFixed(3)}`}
className={`inline-flex items-center gap-0.5 text-[10px] font-medium px-1.5 py-0.5 rounded border ${colorClass}`}
>
<span className="text-[9px] font-normal opacity-70">{label}</span>
{pct}%
</span>
);
}
/** Shimmer pill shown while eval scores are loading. */
function EvalLoadingPill() {
return (
<span
className="inline-block w-24 h-3.5 rounded shimmer"
aria-label="Loading evaluation scores"
/>
);
}
function EvalScoreBadges({ scores }: { scores: EvalScores }) {
return (
<>
<span className="text-[11px] text-slate-300" aria-hidden>Β·</span>
<ScoreBadge label="Ctx" score={scores.relevance_score} />
<ScoreBadge label="Grd" score={scores.groundedness_score} />
<ScoreBadge label="Ans" score={scores.answer_relevance_score} />
</>
);
}
// ── Inline markdown: **bold**, *italic*, `code` ────────────────────────────
function renderInline(text: string): ReactNode {
const parts = text.split(/(\*\*[^*]+\*\*|\*[^*]+\*|`[^`]+`)/g);
return parts.map((part, i) => {
if (part.startsWith("**") && part.endsWith("**"))
return <strong key={i} className="font-semibold text-slate-900">{part.slice(2, -2)}</strong>;
if (part.startsWith("*") && part.endsWith("*"))
return <em key={i} className="italic">{part.slice(1, -1)}</em>;
if (part.startsWith("`") && part.endsWith("`"))
return (
<code key={i} className="bg-slate-100 text-slate-800 text-[0.8em] font-mono px-1.5 py-0.5 rounded border border-slate-200">
{part.slice(1, -1)}
</code>
);
return part;
});
}
// ── Block markdown parser ──────────────────────────────────────────────────
function renderMarkdown(text: string): ReactNode {
const lines = text.split("\n");
const out: ReactNode[] = [];
let i = 0;
let key = 0;
while (i < lines.length) {
const line = lines[i];
// Fenced code block
if (line.startsWith("```")) {
const lang = line.slice(3).trim();
const code: string[] = [];
i++;
while (i < lines.length && !lines[i].startsWith("```")) {
code.push(lines[i]);
i++;
}
out.push(
<div key={key++} className="my-3 rounded-lg overflow-hidden border border-slate-200 text-xs">
{lang && (
<div className="bg-slate-800 px-3 py-1.5 font-mono text-slate-400 uppercase tracking-widest text-[10px]">
{lang}
</div>
)}
<pre className="bg-slate-900 text-slate-100 px-4 py-3 font-mono overflow-x-auto leading-relaxed">
<code>{code.join("\n")}</code>
</pre>
</div>,
);
}
// H3
else if (line.startsWith("### ")) {
out.push(
<h3 key={key++} className="font-semibold text-slate-800 text-sm mt-4 mb-1">
{renderInline(line.slice(4))}
</h3>,
);
}
// H2
else if (line.startsWith("## ")) {
out.push(
<h2 key={key++} className="font-semibold text-slate-900 text-[0.9375rem] mt-5 mb-1.5">
{renderInline(line.slice(3))}
</h2>,
);
}
// H1
else if (line.startsWith("# ")) {
out.push(
<h1 key={key++} className="font-bold text-slate-900 text-base mt-5 mb-2">
{renderInline(line.slice(2))}
</h1>,
);
}
// Unordered list β€” collect consecutive items
else if (/^[-*+] /.test(line)) {
const items: string[] = [];
while (i < lines.length && /^[-*+] /.test(lines[i])) {
items.push(lines[i].replace(/^[-*+] /, ""));
i++;
}
out.push(
<ul key={key++} className="my-2 space-y-1.5 pl-1">
{items.map((item, idx) => (
<li key={idx} className="flex gap-2.5 text-sm text-slate-700 leading-relaxed">
<span className="mt-2 w-1.5 h-1.5 rounded-full bg-brand-400 shrink-0" />
<span>{renderInline(item)}</span>
</li>
))}
</ul>,
);
continue;
}
// Ordered list
else if (/^\d+\. /.test(line)) {
const items: string[] = [];
while (i < lines.length && /^\d+\. /.test(lines[i])) {
items.push(lines[i].replace(/^\d+\. /, ""));
i++;
}
out.push(
<ol key={key++} className="my-2 space-y-1.5 pl-1">
{items.map((item, idx) => (
<li key={idx} className="flex gap-2.5 text-sm text-slate-700 leading-relaxed">
<span className="shrink-0 text-brand-500 font-semibold tabular-nums text-xs mt-0.5 w-4 text-right">
{idx + 1}.
</span>
<span>{renderInline(item)}</span>
</li>
))}
</ol>,
);
continue;
}
// Horizontal rule
else if (/^---+$/.test(line.trim())) {
out.push(<hr key={key++} className="my-4 border-slate-200" />);
}
// Blank line β†’ spacer paragraph gap
else if (line.trim() === "") {
// handled by space-y on the container
}
// Paragraph
else {
out.push(
<p key={key++} className="text-sm text-slate-700 leading-relaxed">
{renderInline(line)}
</p>,
);
}
i++;
}
return <div className="space-y-1.5">{out}</div>;
}
// ── Component ──────────────────────────────────────────────────────────────
export default function ResponseDisplay({ response, showInlineChart = true, evalState }: Props) {
const time = new Date(response.created_at).toLocaleTimeString([], {
hour: "2-digit",
minute: "2-digit",
});
const PROVIDER_LABELS: Record<string, string> = {
openai: "OpenAI",
azure_openai: "Azure OpenAI",
gemini: "Gemini",
};
return (
<div className="animate-slide-up space-y-2">
{/* User query β€” right-aligned bubble */}
<div className="flex justify-end">
<div className="max-w-[80%] bg-brand-600 text-white rounded-2xl rounded-tr-sm px-4 py-2.5 text-sm leading-relaxed shadow-sm">
{response.query}
</div>
</div>
{/* AI response β€” left-aligned with avatar */}
<div className="flex gap-2.5 items-start">
{/* Avatar */}
<div
className="w-7 h-7 rounded-full bg-white border border-slate-200 shadow-sm flex items-center justify-center shrink-0 mt-0.5"
aria-hidden="true"
>
<svg className="w-3.5 h-3.5 text-brand-600" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.75}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9.813 15.904 9 18.75l-.813-2.846a4.5 4.5 0 0 0-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 0 0 3.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 0 0 3.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 0 0-3.09 3.09Z" />
</svg>
</div>
{/* Response card */}
<div className="flex-1 min-w-0 bg-white rounded-2xl rounded-tl-sm border border-slate-200 shadow-sm overflow-hidden">
{/* Answer body */}
<div className="px-5 pt-4 pb-3">
{renderMarkdown(response.answer)}
</div>
{/* Inline chart (only when viz panel is not active) */}
{showInlineChart && response.chart_data && (
<div className="px-5 pb-4">
<PlotlyChart spec={response.chart_data} />
</div>
)}
{/* Citations */}
{response.citations.length > 0 && (
<div className="px-5 pb-4 border-t border-slate-100 pt-3">
<CitationList citations={response.citations} />
</div>
)}
{/* Metadata footer */}
<div className="px-5 py-2 bg-slate-50 border-t border-slate-100 flex items-center gap-2 flex-wrap">
<span className="text-[11px] text-slate-400 tabular-nums">{time}</span>
<span className="text-[11px] text-slate-300" aria-hidden>Β·</span>
<span className="text-[11px] text-slate-400">
{PROVIDER_LABELS[response.model_provider] ?? response.model_provider}
</span>
<span className="text-[11px] text-slate-300" aria-hidden>Β·</span>
<span className="text-[11px] text-slate-400">
{response.agent_steps} step{response.agent_steps !== 1 ? "s" : ""}
</span>
{evalState?.loading && <EvalLoadingPill />}
{evalState && !evalState.loading && evalState.scores && (
<EvalScoreBadges scores={evalState.scores} />
)}
</div>
</div>
</div>
</div>
);
}