"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 = { "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 ( model {modelShort} {mf.fit_score != null && ( <> · {Math.round(mf.fit_score)}/100 )} {mf.quantization && ( <> · {mf.quantization} )} {mf.estimated_vram_gb != null && ( <> · {mf.estimated_vram_gb.toFixed(1)} GB )} {mf.estimate_used && est.} ); } function TypingDots() { return ( ); } const ROUTE_META: Record = { 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 (
{meta.icon} {meta.label} {rationale && ( {rationale} )}
); } const STEP_GLYPH: Record = { 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 (
Agentic reasoning {live && }
    {steps.map((s, i) => (
  1. {STEP_GLYPH[s.phase] ?? "·"} {s.phase === "hop" ? ( <> Hop {s.hop}:{" "} {s.query} {s.retrieved != null && · {s.retrieved} passages} ) : s.phase === "decompose" ? ( <> {s.label} {s.sub_questions?.length ? ( — {s.sub_questions.join(" · ")} ) : null} ) : s.phase === "check" ? ( {s.sufficient ? "Evidence sufficient" : "Needs more"} {!s.sufficient && s.detail ? — {s.detail} : null} ) : ( {s.label} )}
  2. ))}
); } function AiDot() { return ( AI ); } 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 (
{turn.voice && ( 🎙 )} {turn.text}
); } // Corpus-inventory answer renders its own card. if (turn.inventory) { return (
); } const empty = !turn.text; const live = streaming && isLast; const hasCitations = (turn.citations?.length ?? 0) > 0; return (
{turn.route && } {turn.steps?.length ? : null} {empty && live ? ( ) : turn.error ? (

{turn.text}

) : (
{live && ( )}
)} {turn.insufficient && ( )} {hasCitations && } {!streaming && turn.model_fit && turn.model_fit.fit_score != null && ( )} {!streaming && !turn.error && turn.confidence != null && turn.confidence > 0 && ( )} {!streaming && !empty && !turn.error && (
turn.text} label="Copy" /> {hasCitations && ( {turn.citations!.length} source{turn.citations!.length === 1 ? "" : "s"} )} {isLast && onRegenerate && ( )}
)}
); }