Spaces:
Paused
Paused
| 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> | |
| ); | |
| } | |