"use client"; import { useState, useMemo } from "react"; import { Database, Globe, ChevronDown, ChevronRight, Star } from "lucide-react"; import { Button } from "@/components/ui/button"; import { cn } from "@/lib/utils"; interface SourceReference { id?: number; title: string; filename?: string; page?: number; score?: number; preview: string; url?: string; source_type?: "web" | "rag"; } interface SourceReferencesProps { sources: SourceReference[]; maxSources?: number; answerContent?: string; messageId?: string; } /** 从 answer 中提取长度 >= minLen 的不重叠片段,用于在来源原文中高亮匹配 */ function findMatchedSpans( answer: string, sourceText: string, minLen = 15 ): Array<{ start: number; end: number }> { if (!answer || !sourceText) return []; // 从 answer 中切分出所有有意义的中文/英文片段 const segments: string[] = []; // 按句号、换行等断开 const sentences = answer.split(/[。,;!?\n、:()]/); for (const s of sentences) { const trimmed = s.trim(); if (trimmed.length >= minLen) { segments.push(trimmed); } } // 在 sourceText 中查找每个片段 const spans: Array<{ start: number; end: number }> = []; for (const seg of segments) { let pos = 0; while (pos < sourceText.length) { const idx = sourceText.indexOf(seg, pos); if (idx === -1) break; const end = idx + seg.length; // 检查是否与已有 span 重叠,有则合并 const overlapping = spans.find( (s) => !(end <= s.start || idx >= s.end) ); if (overlapping) { overlapping.start = Math.min(overlapping.start, idx); overlapping.end = Math.max(overlapping.end, end); } else { spans.push({ start: idx, end }); } pos = end; } } return spans.sort((a, b) => a.start - b.start); } /** 将匹配的 span 用 包裹 */ function highlightText( text: string, spans: Array<{ start: number; end: number }> ): React.ReactNode { if (!spans.length) return text; // 合并重叠/相邻的 span const merged: Array<{ start: number; end: number }> = []; for (const span of spans) { const last = merged[merged.length - 1]; if (last && span.start <= last.end + 3) { last.end = Math.max(last.end, span.end); } else { merged.push({ ...span }); } } const parts: React.ReactNode[] = []; let last = 0; for (const span of merged) { if (span.start > last) { parts.push(text.slice(last, span.start)); } parts.push( {text.slice(span.start, span.end)} ); last = span.end; } if (last < text.length) { parts.push(text.slice(last)); } return <>{parts}; } function SourceRow({ source, answerContent, messageId, }: { source: SourceReference; answerContent?: string; messageId?: string; }) { const [showPreview, setShowPreview] = useState(false); const matchedSpans = useMemo(() => { if (!answerContent || !source.preview) return []; return findMatchedSpans(answerContent, source.preview); }, [answerContent, source.preview]); const previewContent = useMemo(() => { if (!matchedSpans.length) return source.preview; return highlightText(source.preview, matchedSpans); // eslint-disable-next-line react-hooks/exhaustive-deps }, [showPreview, matchedSpans]); return (
{showPreview && (
{previewContent}
)}
); } export default function SourceReferences({ sources, maxSources = 20, answerContent, messageId, }: SourceReferencesProps) { const [expanded, setExpanded] = useState(false); if (!sources || sources.length === 0) { return null; } const displaySources = sources.slice(0, maxSources); const showExpandButton = sources.length > maxSources; const visibleSources = expanded ? sources : displaySources; const ragSources = visibleSources.filter((s) => s.source_type !== "web"); const webSources = visibleSources.filter((s) => s.source_type === "web"); const sourceSection = (
{ragSources.length > 0 && (
知识库来源 ({ragSources.length} {expanded && showExpandButton ? `/${sources.filter((s) => s.source_type !== "web").length}` : ""} )
{ragSources.map((source, i) => ( ))}
)} {webSources.length > 0 && (
网络来源 ({webSources.length} {expanded && showExpandButton ? `/${sources.filter((s) => s.source_type === "web").length}` : ""} )
{webSources.map((source, i) => ( ))}
)}
); return (
{expanded ? (
{sourceSection}
) : ( sourceSection )} {showExpandButton && ( )}
); }