feat: RAG inline citations, source highlighting, and admin panel

- Increase RAG retrieval from 5 to 50 docs with relevance threshold (0.15)
- LLM inline citations with [来源N] format and reference list
- Clickable citation links scroll to source cards with highlight animation
- Source previews with full chunk text and answer-matched highlighting
- Message-scoped source IDs to fix cross-round citation targeting
- Admin panel pages (knowledge, users, forum, course, settings)
- Add rehype-raw dependency for HTML-in-markdown rendering

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-27 20:51:28 +08:00
parent 5d42a0573a
commit abcced35b8
23 changed files with 2249 additions and 112 deletions
+251 -89
View File
@@ -1,10 +1,13 @@
"use client";
import { FileText, ExternalLink, Star, Database, Globe } from "lucide-react";
import { useState, useMemo } from "react";
import { Database, Globe, ChevronDown, ChevronRight, Star } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { ScrollArea } from "@/components/ui/scroll-area";
import { cn } from "@/lib/utils";
interface SourceReference {
id?: number;
title: string;
filename?: string;
page?: number;
@@ -17,101 +20,260 @@ interface SourceReference {
interface SourceReferencesProps {
sources: SourceReference[];
maxSources?: number;
answerContent?: string;
messageId?: string;
}
export default function SourceReferences({ sources, maxSources = 5 }: SourceReferencesProps) {
if (!sources || sources.length === 0) {
return null;
/** 从 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);
}
}
const displaySources = sources.slice(0, maxSources);
const ragSources = displaySources.filter(s => s.source_type !== "web");
const webSources = displaySources.filter(s => s.source_type === "web");
// 在 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 用 <mark> 包裹 */
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(
<mark key={span.start} className="bg-yellow-200/70 dark:bg-yellow-500/30 rounded-sm px-0.5">
{text.slice(span.start, span.end)}
</mark>
);
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 (
<div className="mt-4 space-y-3">
{ragSources.length > 0 && (
<div className="space-y-2">
<div className="flex items-center gap-2 text-sm text-blue-600">
<Database className="h-4 w-4" />
<span> ({ragSources.length})</span>
</div>
<div className="space-y-2">
{ragSources.map((source, index) => (
<Card key={index} className="border border-blue-200 bg-blue-50/30 hover:border-blue-300 transition-colors">
<CardHeader className="pb-2">
<div className="flex items-start justify-between">
<CardTitle className="text-sm font-medium line-clamp-2">
{source.title}
</CardTitle>
{source.score != null && source.score > 0 && source.score < 1 && (
<div className="flex items-center gap-1 ml-2">
<Star className="h-3 w-3 text-yellow-500" />
<span className="text-xs text-gray-500">
{(source.score * 100).toFixed(1)}%
</span>
</div>
)}
</div>
<div className="text-xs text-gray-500">
{source.filename}
{source.page && ` • 第 ${source.page}`}
</div>
</CardHeader>
<CardContent className="pt-0">
<p className="text-xs text-gray-600 line-clamp-2">
{source.preview}
</p>
</CardContent>
</Card>
))}
</div>
</div>
)}
{webSources.length > 0 && (
<div className="space-y-2">
<div className="flex items-center gap-2 text-sm text-green-600">
<Globe className="h-4 w-4" />
<span> ({webSources.length})</span>
</div>
<div className="space-y-2">
{webSources.map((source, index) => (
<Card key={index} className="border border-green-200 bg-green-50/30 hover:border-green-300 transition-colors">
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium line-clamp-2">
{source.title}
</CardTitle>
</CardHeader>
<CardContent className="pt-0">
<p className="text-xs text-gray-600 line-clamp-2 mb-2">
{source.preview}
</p>
{source.url && (
<Button
size="sm"
variant="outline"
className="h-6 text-xs"
onClick={() => window.open(source.url, '_blank')}
>
<ExternalLink className="h-3 w-3 mr-1" />
</Button>
)}
</CardContent>
</Card>
))}
</div>
</div>
)}
{sources.length > maxSources && (
<div className="text-xs text-gray-500 text-center">
{sources.length - maxSources}
<div
id={source.id != null && messageId ? `source-${messageId}-${source.id}` : undefined}
className="scroll-mt-20"
>
<button
className={cn(
"w-full flex items-center gap-1.5 px-2 py-1 -mx-2 rounded text-left",
"hover:bg-muted/60 transition-colors group text-xs"
)}
onClick={() => setShowPreview(!showPreview)}
>
<ChevronRight
className={cn(
"h-3 w-3 flex-shrink-0 text-muted-foreground/60 transition-transform",
showPreview && "rotate-90"
)}
/>
{source.id != null && (
<span className="inline-flex items-center justify-center min-w-[18px] h-[18px] rounded-full bg-primary/10 text-primary text-[10px] font-bold flex-shrink-0">
{source.id}
</span>
)}
<span className="font-medium truncate">{source.title}</span>
{source.filename && (
<span className="text-muted-foreground/70 truncate hidden sm:inline">
{source.filename.replace(/\.(pdf|docx?|txt|md)$/i, "")}
</span>
)}
{source.score != null && source.score > 0 && source.score < 1 && (
<span className="flex items-center gap-0.5 text-[10px] text-muted-foreground flex-shrink-0 ml-auto">
<Star className="h-2.5 w-2.5 text-yellow-500" />
{(source.score * 100).toFixed(0)}%
</span>
)}
</button>
{showPreview && (
<div className="ml-7 pl-3 pr-2 py-1.5 mb-0.5 border-l-2 border-primary/20 bg-muted/30 rounded-r text-xs text-muted-foreground leading-relaxed max-h-48 overflow-y-auto">
{previewContent}
</div>
)}
</div>
);
}
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 = (
<div className="space-y-1">
{ragSources.length > 0 && (
<div className="space-y-1">
<div className="flex items-center gap-1.5 text-[11px] font-medium text-muted-foreground px-0.5">
<Database className="h-3 w-3 text-blue-500" />
<span>
({ragSources.length}
{expanded && showExpandButton
? `/${sources.filter((s) => s.source_type !== "web").length}`
: ""}
)
</span>
</div>
<div className="divide-y divide-border/30">
{ragSources.map((source, i) => (
<SourceRow
key={i}
source={source}
answerContent={answerContent}
messageId={messageId}
/>
))}
</div>
</div>
)}
{webSources.length > 0 && (
<div className="space-y-1">
<div className="flex items-center gap-1.5 text-[11px] font-medium text-muted-foreground px-0.5">
<Globe className="h-3 w-3 text-green-500" />
<span>
({webSources.length}
{expanded && showExpandButton
? `/${sources.filter((s) => s.source_type === "web").length}`
: ""}
)
</span>
</div>
<div className="divide-y divide-border/30">
{webSources.map((source, i) => (
<SourceRow
key={i}
source={source}
answerContent={answerContent}
messageId={messageId}
/>
))}
</div>
</div>
)}
</div>
);
return (
<div className="mt-3 space-y-2">
{expanded ? (
<ScrollArea className="max-h-96">
<div className="pr-3">{sourceSection}</div>
</ScrollArea>
) : (
sourceSection
)}
{showExpandButton && (
<Button
variant="ghost"
size="sm"
className="w-full h-7 text-[11px] text-muted-foreground"
onClick={() => setExpanded(!expanded)}
>
{expanded
? `收起(共 ${sources.length} 个)`
: `展开全部 ${sources.length} 个来源`}
<ChevronDown
className={cn(
"ml-1 h-3 w-3 transition-transform",
expanded && "rotate-180"
)}
/>
</Button>
)}
</div>
);
}