auralynq-rag / web /components /Message.tsx
asdfasdfqrqwer's picture
sync: bring the Space up to the current GitHub tree
656439d
Raw
History Blame Contribute Delete
10.3 kB
"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>
);
}