Spaces:
Paused
Paused
| "use client"; | |
| import Link from "next/link"; | |
| import type { Citation, CorpusSummary, InsufficientReason, ModelFitSnapshot } from "@/lib/api"; | |
| import { Citations } from "@/components/Citations"; | |
| import { ConfidenceBar } from "@/components/ConfidenceBar"; | |
| import { Markdown } from "@/components/Markdown"; | |
| import { CopyButton } from "@/components/CopyButton"; | |
| import { InsufficientEvidence } from "@/components/InsufficientEvidence"; | |
| import { CorpusInventory } from "@/components/CorpusInventory"; | |
| export interface AgenticStep { | |
| phase: "decompose" | "hop" | "check" | "synthesize"; | |
| label: string; | |
| detail?: string; | |
| hop?: number; | |
| query?: string; | |
| retrieved?: number; | |
| sub_questions?: string[]; | |
| sufficient?: boolean; | |
| } | |
| export interface Turn { | |
| role: "user" | "assistant"; | |
| text: string; | |
| citations?: Citation[]; | |
| route?: string; | |
| rationale?: string; | |
| steps?: AgenticStep[]; | |
| voice?: boolean; | |
| error?: boolean; | |
| status?: string; | |
| insufficient?: InsufficientReason | null; | |
| inventory?: CorpusSummary | null; | |
| question?: string; | |
| confidence?: number; | |
| semanticCoverage?: number; | |
| model_fit?: ModelFitSnapshot | null; | |
| } | |
| // Fit-level → semantic token (theme-aware across dark / light / comfort). | |
| const FIT_COLOR: Record<string, string> = { | |
| "Excellent fit": "text-ok", | |
| "Recommended": "text-brand", | |
| "Usable with limits": "text-warn", | |
| "Not recommended": "text-warn", | |
| "Does not fit": "text-bad", | |
| }; | |
| function ModelFitChip({ mf }: { mf: ModelFitSnapshot }) { | |
| const modelShort = mf.selected_model.replace(/^(ollama:|local:|hf:)/, ""); | |
| const labelColor = FIT_COLOR[mf.fit_level ?? ""] ?? "text-fg3"; | |
| return ( | |
| <Link | |
| href="/modelfit" | |
| className="chip mt-2 !text-[11px] hover:border-edge2 transition-colors" | |
| title={`ModelFit: ${mf.fit_level ?? "scored"} — open ModelFit Index`} | |
| > | |
| <span className="text-fg3">model</span> | |
| <span className="max-w-[120px] truncate font-mono text-fg2">{modelShort}</span> | |
| {mf.fit_score != null && ( | |
| <> | |
| <span className="text-fg3">·</span> | |
| <span className={`font-semibold ${labelColor}`}>{Math.round(mf.fit_score)}/100</span> | |
| </> | |
| )} | |
| {mf.quantization && ( | |
| <> | |
| <span className="text-fg3">·</span> | |
| <span className="font-mono text-fg3">{mf.quantization}</span> | |
| </> | |
| )} | |
| {mf.estimated_vram_gb != null && ( | |
| <> | |
| <span className="text-fg3">·</span> | |
| <span className="text-fg3">{mf.estimated_vram_gb.toFixed(1)} GB</span> | |
| </> | |
| )} | |
| {mf.estimate_used && <span className="italic text-fg3">est.</span>} | |
| </Link> | |
| ); | |
| } | |
| function TypingDots() { | |
| return ( | |
| <span className="inline-flex items-center gap-1 py-2" aria-label="Assistant is typing"> | |
| <span className="typing-dot" /> | |
| <span className="typing-dot" style={{ animationDelay: "0.18s" }} /> | |
| <span className="typing-dot" style={{ animationDelay: "0.36s" }} /> | |
| </span> | |
| ); | |
| } | |
| const ROUTE_META: Record<string, { label: string; color: string; icon: string }> = { | |
| fast: { label: "Fast retrieval", color: "border-brand/40 text-brand", icon: "⚡" }, | |
| hybrid: { label: "Hybrid retrieval", color: "border-accent/40 text-accent", icon: "◈" }, | |
| graph: { label: "Graph traversal", color: "border-brand2/40 text-brand2", icon: "◎" }, | |
| }; | |
| function RouteTag({ route, rationale }: { route: string; rationale?: string }) { | |
| const meta = ROUTE_META[route] ?? { label: route, color: "border-edge text-fg3", icon: "·" }; | |
| return ( | |
| <div className="mb-3 flex flex-wrap items-center gap-2 border-b border-edge/40 pb-3"> | |
| <span className={`tag font-semibold ${meta.color}`}> | |
| <span aria-hidden>{meta.icon}</span> | |
| {meta.label} | |
| </span> | |
| {rationale && ( | |
| <span className="truncate max-w-xs text-xs text-fg3" title={rationale}> | |
| {rationale} | |
| </span> | |
| )} | |
| </div> | |
| ); | |
| } | |
| const STEP_GLYPH: Record<AgenticStep["phase"], string> = { | |
| decompose: "◆", | |
| hop: "→", | |
| check: "✓", | |
| synthesize: "✎", | |
| }; | |
| /** Live multi-hop reasoning trace for the agentic strategy. */ | |
| function AgenticSteps({ steps, live }: { steps: AgenticStep[]; live?: boolean }) { | |
| if (!steps.length) return null; | |
| return ( | |
| <div className="mb-3 rounded-lg border border-edge/60 bg-panel/40 px-3 py-2"> | |
| <div className="mb-1.5 flex items-center gap-1.5 text-[10px] font-semibold uppercase tracking-wider text-fg3"> | |
| Agentic reasoning | |
| {live && <span className="inline-block h-1.5 w-1.5 rounded-full bg-brand animate-pulse" aria-hidden />} | |
| </div> | |
| <ol className="space-y-1"> | |
| {steps.map((s, i) => ( | |
| <li key={i} className="flex items-start gap-2 text-xs leading-relaxed"> | |
| <span | |
| className={`mt-0.5 shrink-0 font-mono ${ | |
| s.phase === "check" && s.sufficient === false ? "text-warn" : "text-brand" | |
| }`} | |
| aria-hidden | |
| > | |
| {STEP_GLYPH[s.phase] ?? "·"} | |
| </span> | |
| <span className="min-w-0 flex-1 text-fg2"> | |
| {s.phase === "hop" ? ( | |
| <> | |
| <span className="text-fg3">Hop {s.hop}:</span>{" "} | |
| <span className="font-medium text-fg">{s.query}</span> | |
| {s.retrieved != null && <span className="text-fg3"> · {s.retrieved} passages</span>} | |
| </> | |
| ) : s.phase === "decompose" ? ( | |
| <> | |
| <span className="text-fg">{s.label}</span> | |
| {s.sub_questions?.length ? ( | |
| <span className="text-fg3"> — {s.sub_questions.join(" · ")}</span> | |
| ) : null} | |
| </> | |
| ) : s.phase === "check" ? ( | |
| <span className="text-fg"> | |
| {s.sufficient ? "Evidence sufficient" : "Needs more"} | |
| {!s.sufficient && s.detail ? <span className="text-fg3"> — {s.detail}</span> : null} | |
| </span> | |
| ) : ( | |
| <span className="text-fg">{s.label}</span> | |
| )} | |
| </span> | |
| </li> | |
| ))} | |
| </ol> | |
| </div> | |
| ); | |
| } | |
| function AiDot() { | |
| return ( | |
| <span | |
| className="mt-1 flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-gradient-to-br from-brand/30 to-brand2/30 ring-1 ring-brand/20 text-[10px] font-bold text-brand" | |
| aria-hidden | |
| > | |
| AI | |
| </span> | |
| ); | |
| } | |
| export function Message({ | |
| turn, | |
| streaming, | |
| isLast, | |
| onRegenerate, | |
| onAsk, | |
| onIngest, | |
| onOpenSource, | |
| }: { | |
| turn: Turn; | |
| streaming: boolean; | |
| isLast: boolean; | |
| onRegenerate?: () => void; | |
| onAsk?: (q: string) => void; | |
| onIngest?: () => void; | |
| onOpenSource?: (marker: number) => void; | |
| }) { | |
| if (turn.role === "user") { | |
| return ( | |
| <div className="flex justify-end"> | |
| <div className="max-w-[85%] whitespace-pre-wrap rounded-2xl rounded-br-sm bg-gradient-to-br from-brand2/25 to-brand/15 px-4 py-3 leading-relaxed shadow-sm ring-1 ring-brand2/20 text-fg"> | |
| {turn.voice && ( | |
| <span className="mr-1.5 text-xs opacity-60" aria-label="voice query"> | |
| 🎙 | |
| </span> | |
| )} | |
| {turn.text} | |
| </div> | |
| </div> | |
| ); | |
| } | |
| // Corpus-inventory answer renders its own card. | |
| if (turn.inventory) { | |
| return ( | |
| <div className="flex items-start gap-2.5"> | |
| <AiDot /> | |
| <div className="flex-1 min-w-0 rounded-2xl rounded-tl-sm border border-edge bg-panel2 px-4 py-4 shadow-md"> | |
| <CorpusInventory summary={turn.inventory} question={turn.question} /> | |
| </div> | |
| </div> | |
| ); | |
| } | |
| const empty = !turn.text; | |
| const live = streaming && isLast; | |
| const hasCitations = (turn.citations?.length ?? 0) > 0; | |
| return ( | |
| <div className="group flex items-start gap-2.5"> | |
| <AiDot /> | |
| <div className="flex-1 min-w-0 rounded-2xl rounded-tl-sm border border-edge bg-panel2 px-4 py-3.5 shadow-md"> | |
| {turn.route && <RouteTag route={turn.route} rationale={turn.rationale} />} | |
| {turn.steps?.length ? <AgenticSteps steps={turn.steps} live={live} /> : null} | |
| {empty && live ? ( | |
| <TypingDots /> | |
| ) : turn.error ? ( | |
| <p className="whitespace-pre-wrap leading-relaxed text-bad">{turn.text}</p> | |
| ) : ( | |
| <div className="prose-answer"> | |
| <Markdown | |
| text={turn.text} | |
| streaming={live} | |
| citations={turn.citations} | |
| onOpenCitation={onOpenSource} | |
| /> | |
| {live && ( | |
| <span className="ml-0.5 inline-block h-4 w-2 animate-pulse bg-brand/70 align-middle" /> | |
| )} | |
| </div> | |
| )} | |
| {turn.insufficient && ( | |
| <InsufficientEvidence reason={turn.insufficient} onAsk={onAsk} onIngest={onIngest} /> | |
| )} | |
| {hasCitations && <Citations citations={turn.citations!} onOpenSource={onOpenSource} />} | |
| {!streaming && turn.model_fit && turn.model_fit.fit_score != null && ( | |
| <ModelFitChip mf={turn.model_fit} /> | |
| )} | |
| {!streaming && !turn.error && turn.confidence != null && turn.confidence > 0 && ( | |
| <ConfidenceBar | |
| data={{ | |
| overall: turn.confidence, | |
| semanticCoverage: turn.semanticCoverage, | |
| }} | |
| /> | |
| )} | |
| {!streaming && !empty && !turn.error && ( | |
| <div className="mt-2.5 flex items-center gap-4 opacity-0 transition-opacity duration-150 group-hover:opacity-100 focus-within:opacity-100"> | |
| <CopyButton getText={() => turn.text} label="Copy" /> | |
| {hasCitations && ( | |
| <span className="text-xs text-fg3"> | |
| {turn.citations!.length} source{turn.citations!.length === 1 ? "" : "s"} | |
| </span> | |
| )} | |
| {isLast && onRegenerate && ( | |
| <button | |
| type="button" | |
| onClick={onRegenerate} | |
| aria-label="Regenerate answer" | |
| className="ml-auto inline-flex items-center gap-1 text-xs text-fg3 transition hover:text-brand" | |
| > | |
| ↻ Regenerate | |
| </button> | |
| )} | |
| </div> | |
| )} | |
| </div> | |
| </div> | |
| ); | |
| } | |