| 'use client'; |
|
|
| import { motion, AnimatePresence } from 'framer-motion'; |
| import { cn } from '@/lib/utils'; |
| import type { RagChunk } from '@/types'; |
|
|
| interface RagChunksPanelProps { |
| chunks: RagChunk[]; |
| isLoading: boolean; |
| onChunkClick: (chunk: RagChunk) => void; |
| } |
|
|
| export function RagChunksPanel({ chunks, isLoading, onChunkClick }: RagChunksPanelProps) { |
| return ( |
| <div className="flex flex-col gap-3 w-full"> |
| {/* Header */} |
| <div className="flex items-center gap-2"> |
| <svg |
| className="w-4 h-4 text-[var(--foreground)]/40 flex-shrink-0" |
| fill="none" |
| viewBox="0 0 24 24" |
| stroke="currentColor" |
| strokeWidth={1.5} |
| > |
| <path |
| strokeLinecap="round" |
| strokeLinejoin="round" |
| d="M20.25 6.375c0 2.278-3.694 4.125-8.25 4.125S3.75 8.653 3.75 6.375m16.5 0c0-2.278-3.694-4.125-8.25-4.125S3.75 4.097 3.75 6.375m16.5 0v11.25c0 2.278-3.694 4.125-8.25 4.125s-8.25-1.847-8.25-4.125V6.375m16.5 5.625c0 2.278-3.694 4.125-8.25 4.125s-8.25-1.847-8.25-4.125" |
| /> |
| </svg> |
| <span className="text-xs font-medium text-[var(--foreground)]/40 uppercase tracking-wider"> |
| {isLoading |
| ? 'Searching sources...' |
| : `${chunks.length} relevant source${chunks.length !== 1 ? 's' : ''}`} |
| </span> |
| </div> |
| |
| {/* Skeleton loading */} |
| {isLoading && ( |
| <div className="flex flex-col gap-2"> |
| {[0, 1, 2].map((i) => ( |
| <div |
| key={i} |
| className={cn( |
| 'rounded-xl p-3', |
| 'bg-[var(--glass-bg-muted)]', |
| 'border border-[var(--glass-border-subtle)]', |
| 'animate-pulse' |
| )} |
| style={{ animationDelay: `${i * 100}ms` }} |
| > |
| <div className="h-3 bg-[var(--glass-bg)] rounded w-2/3 mb-2" /> |
| <div className="h-2.5 bg-[var(--glass-bg)] rounded w-full mb-1.5" /> |
| <div className="h-2.5 bg-[var(--glass-bg)] rounded w-4/5" /> |
| </div> |
| ))} |
| </div> |
| )} |
| |
| {/* Chunk cards */} |
| {!isLoading && ( |
| <AnimatePresence> |
| <div className="flex flex-col gap-2"> |
| {chunks.map((chunk, index) => ( |
| <ChunkCard |
| key={chunk.id} |
| chunk={chunk} |
| index={index} |
| onClick={() => onChunkClick(chunk)} |
| /> |
| ))} |
| </div> |
| </AnimatePresence> |
| )} |
| |
| {/* Subtle CTA */} |
| {!isLoading && chunks.length > 0 && ( |
| <p className="text-xs text-[var(--foreground)]/30 text-center pt-1"> |
| Click a source to read the full excerpt |
| </p> |
| )} |
| </div> |
| ); |
| } |
|
|
| function ChunkCard({ |
| chunk, |
| index, |
| onClick, |
| }: { |
| chunk: RagChunk; |
| index: number; |
| onClick: () => void; |
| }) { |
| const snippet = chunk.text.slice(0, 140).trimEnd(); |
| const hasMore = chunk.text.length > 140; |
|
|
| return ( |
| <motion.button |
| initial={{ opacity: 0, y: 8 }} |
| animate={{ opacity: 1, y: 0 }} |
| transition={{ duration: 0.2, delay: index * 0.05 }} |
| onClick={onClick} |
| className={cn( |
| 'text-left w-full rounded-xl p-3', |
| 'bg-[var(--glass-bg-muted)]', |
| 'border border-[var(--glass-border-subtle)]', |
| 'hover:bg-[var(--glass-bg)] hover:border-[var(--glass-border)]', |
| 'transition-all duration-150 group', |
| 'focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--suggestion-accent)]' |
| )} |
| > |
| {/* Title row */} |
| <div className="flex items-start justify-between gap-2 mb-1.5"> |
| <span className="text-xs font-medium text-[var(--foreground)]/70 truncate flex-1 leading-tight group-hover:text-[var(--foreground)] transition-colors"> |
| {chunk.title || chunk.source || 'Untitled source'} |
| </span> |
| <ScoreBadge score={chunk.score} /> |
| </div> |
| |
| {/* Snippet */} |
| <p className="text-xs text-[var(--foreground)]/50 leading-relaxed line-clamp-2"> |
| {snippet} |
| {hasMore && '…'} |
| </p> |
| </motion.button> |
| ); |
| } |
|
|
| function sigmoid(x: number) { |
| return 1 / (1 + Math.exp(-x)); |
| } |
|
|
| function ScoreBadge({ score }: { score: number }) { |
| const prob = sigmoid(score); |
| const pct = Math.round(prob * 100); |
|
|
| const colorClass = |
| prob >= 0.7 |
| ? 'bg-[var(--success-bg)] text-[var(--success-text)]' |
| : prob >= 0.4 |
| ? 'bg-[var(--warning-bg)] text-[var(--warning-text)]' |
| : 'bg-[var(--glass-bg)] text-[var(--foreground)]/40'; |
|
|
| return ( |
| <span className={cn('text-[10px] font-medium px-1.5 py-0.5 rounded-full flex-shrink-0', colorClass)}> |
| {pct}% |
| </span> |
| ); |
| } |
|
|