294 lines
9.9 KiB
TypeScript
294 lines
9.9 KiB
TypeScript
import { ExternalLinkIcon, FileTextIcon } from "lucide-react";
|
||
|
||
import { Button } from "@/components/ui/button";
|
||
import {
|
||
Dialog,
|
||
DialogContent,
|
||
DialogDescription,
|
||
DialogHeader,
|
||
DialogTitle,
|
||
} from "@/components/ui/dialog";
|
||
import type { RuntimeDisplaySource } from "@/core/skill-display/runtime";
|
||
import { resolveWeKnoraSourceTarget } from "@/core/llmwiki/source-links";
|
||
import { useLocalSettings } from "@/core/settings";
|
||
import { useCitationDisplaySettings } from "@/core/system-settings/hooks";
|
||
import { cn } from "@/lib/utils";
|
||
import { useState } from "react";
|
||
import { WeKnoraSourceDrawer } from "@/strategy-components/components/resource-management/llmwiki/WeKnoraSourceDrawer";
|
||
|
||
export function isWeKnoraSourcePreviewUrl(url?: string): boolean {
|
||
return resolveWeKnoraSourceTarget({ url }) !== null;
|
||
}
|
||
|
||
export function SkillDisplaySources({
|
||
highlightedReferenceId,
|
||
sources,
|
||
className,
|
||
}: {
|
||
highlightedReferenceId?: string | null;
|
||
sources: RuntimeDisplaySource[];
|
||
className?: string;
|
||
}) {
|
||
if (sources.length === 0) {
|
||
return null;
|
||
}
|
||
|
||
return (
|
||
<div className={cn("not-prose grid gap-2", className)}>
|
||
{sources.map((source) => (
|
||
<SourceCard
|
||
key={`${source.skillName}-${source.index}`}
|
||
highlighted={highlightedReferenceId === String(source.index)}
|
||
source={source}
|
||
referenceTarget
|
||
/>
|
||
))}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export function SourceCard({
|
||
highlighted = false,
|
||
source,
|
||
referenceTarget = false,
|
||
}: {
|
||
highlighted?: boolean;
|
||
source: RuntimeDisplaySource;
|
||
referenceTarget?: boolean;
|
||
}) {
|
||
const [relatedOpen, setRelatedOpen] = useState(false);
|
||
const [detailOpen, setDetailOpen] = useState(false);
|
||
const [settings] = useLocalSettings();
|
||
const { data: citationDisplay } = useCitationDisplaySettings();
|
||
const previewTarget = resolveWeKnoraSourceTarget({
|
||
...(source.raw || {}),
|
||
url: source.url,
|
||
});
|
||
const cleanupWords =
|
||
citationDisplay?.cleanup_words ?? settings.display.citationCleanupWords ?? [];
|
||
const content = cleanReferenceText(source.content || source.snippet, cleanupWords);
|
||
const snippet = compactReferenceText(content).slice(0, 280);
|
||
const paragraphs = segmentReferenceText(content);
|
||
const score =
|
||
typeof source.score === "number"
|
||
? source.score <= 1
|
||
? `${Math.round(source.score * 100)}%`
|
||
: String(source.score)
|
||
: null;
|
||
const meta = [
|
||
source.source,
|
||
source.author ? `作者 ${source.author}` : null,
|
||
source.type && source.type !== "result" ? source.type : null,
|
||
source.time,
|
||
score ? `相关度 ${score}` : null,
|
||
].filter(Boolean);
|
||
const isCitation = source.mode === "citation";
|
||
const showReferenceNumber = isCitation;
|
||
|
||
return (
|
||
<>
|
||
<div
|
||
id={referenceTarget ? `reference-${source.index}` : undefined}
|
||
role="button"
|
||
tabIndex={0}
|
||
className={cn(
|
||
"group bg-card/60 hover:bg-card hover:border-primary/40 focus-visible:ring-primary/30 w-full scroll-mt-12 rounded-xl border px-3.5 py-3 text-left shadow-sm transition-all focus-visible:ring-2 focus-visible:outline-none",
|
||
"target:border-amber-300 target:bg-amber-50/80",
|
||
highlighted &&
|
||
"border-amber-300 bg-amber-50/80 shadow-[0_0_0_2px_rgba(251,191,36,0.14)]",
|
||
"cursor-pointer",
|
||
)}
|
||
onClick={() => setRelatedOpen(true)}
|
||
onKeyDown={(event) => {
|
||
if (event.key === "Enter" || event.key === " ") {
|
||
event.preventDefault();
|
||
setRelatedOpen(true);
|
||
}
|
||
}}
|
||
>
|
||
<div className="flex items-start gap-3">
|
||
{showReferenceNumber && (
|
||
<span className="bg-primary/10 text-primary border-primary/20 group-hover:bg-primary/15 mt-0.5 inline-flex size-6 shrink-0 items-center justify-center rounded-full border text-xs font-semibold tabular-nums">
|
||
{source.index}
|
||
</span>
|
||
)}
|
||
<div className="min-w-0 flex-1 space-y-1.5">
|
||
<div className="text-foreground line-clamp-2 text-sm leading-snug font-medium">
|
||
{source.title}
|
||
</div>
|
||
{meta.length > 0 && (
|
||
<div className="text-muted-foreground flex flex-wrap items-center gap-x-2 gap-y-0.5 text-[11px]">
|
||
{meta.map((item, index) => (
|
||
<span key={`${item}-${index}`} className="inline-flex items-center">
|
||
{index > 0 && <span className="mr-2 opacity-50">·</span>}
|
||
<span className="truncate">{item}</span>
|
||
</span>
|
||
))}
|
||
</div>
|
||
)}
|
||
{snippet && (
|
||
<p className="text-muted-foreground/90 line-clamp-2 text-xs leading-relaxed">
|
||
{snippet}
|
||
</p>
|
||
)}
|
||
</div>
|
||
{source.url && (
|
||
previewTarget ? (
|
||
<span className="text-muted-foreground bg-muted mt-0.5 inline-flex shrink-0 items-center gap-1 rounded-md px-1.5 py-1 text-[11px] font-medium">
|
||
<FileTextIcon className="size-3.5" />
|
||
知识库文档
|
||
</span>
|
||
) : (
|
||
<ExternalLinkIcon className="text-muted-foreground/60 group-hover:text-primary mt-1 size-3.5 shrink-0 transition-colors" />
|
||
)
|
||
)}
|
||
</div>
|
||
{previewTarget && (
|
||
<div className="mt-3 flex justify-start border-t pt-2">
|
||
<Button
|
||
size="sm"
|
||
variant="ghost"
|
||
className="text-muted-foreground hover:text-primary h-7 px-1.5 text-xs"
|
||
onClick={(event) => {
|
||
event.stopPropagation();
|
||
setDetailOpen(true);
|
||
}}
|
||
>
|
||
<ExternalLinkIcon className="mr-1.5 size-3.5" />
|
||
查看详情
|
||
</Button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
{previewTarget && (
|
||
<WeKnoraSourceDrawer
|
||
open={detailOpen}
|
||
target={previewTarget}
|
||
title={source.title}
|
||
quote={content}
|
||
onOpenChange={setDetailOpen}
|
||
/>
|
||
)}
|
||
<Dialog open={relatedOpen} onOpenChange={setRelatedOpen}>
|
||
<DialogContent className="max-h-[90vh] w-[calc(100vw-2rem)] max-w-3xl overflow-hidden">
|
||
<DialogHeader>
|
||
<div className="flex items-start justify-between gap-3 pr-8">
|
||
<div className="min-w-0 flex-1">
|
||
<div className="flex items-start gap-2">
|
||
{showReferenceNumber && (
|
||
<span className="bg-primary/10 text-primary border-primary/20 mt-1 inline-flex size-6 shrink-0 items-center justify-center rounded-full border text-xs font-semibold tabular-nums">
|
||
{source.index}
|
||
</span>
|
||
)}
|
||
<DialogTitle className="text-base leading-relaxed">
|
||
{source.title}
|
||
</DialogTitle>
|
||
</div>
|
||
{meta.length > 0 && (
|
||
<DialogDescription className="mt-2 flex flex-wrap gap-x-2 gap-y-1 text-xs">
|
||
{meta.map((item, index) => (
|
||
<span key={`${item}-${index}`}>{item}</span>
|
||
))}
|
||
</DialogDescription>
|
||
)}
|
||
</div>
|
||
{source.url && !previewTarget && (
|
||
<Button size="icon-sm" variant="outline" asChild>
|
||
<a
|
||
href={source.url}
|
||
target="_blank"
|
||
rel="noopener noreferrer"
|
||
aria-label="打开来源"
|
||
>
|
||
<ExternalLinkIcon className="size-4" />
|
||
</a>
|
||
</Button>
|
||
)}
|
||
</div>
|
||
</DialogHeader>
|
||
{paragraphs.length > 0 ? (
|
||
<div className="text-foreground max-h-[55vh] space-y-3 overflow-y-auto text-sm leading-relaxed">
|
||
{paragraphs.map((paragraph, index) => (
|
||
<p key={index} className="whitespace-pre-wrap">
|
||
{paragraph}
|
||
</p>
|
||
))}
|
||
</div>
|
||
) : null}
|
||
{source.url && !previewTarget && (
|
||
<div className="text-muted-foreground border-t pt-3 text-xs break-all">
|
||
{source.url}
|
||
</div>
|
||
)}
|
||
</DialogContent>
|
||
</Dialog>
|
||
</>
|
||
);
|
||
}
|
||
|
||
function cleanReferenceText(
|
||
raw: string | undefined,
|
||
cleanupWords: string[],
|
||
): string {
|
||
let text = raw ?? "";
|
||
const words = cleanupWords.map((word) => word.trim()).filter(Boolean);
|
||
if (words.length > 0) {
|
||
text = text
|
||
.replace(/\r\n/g, "\n")
|
||
.split("\n")
|
||
.filter((line) => !words.some((word) => line.includes(word)))
|
||
.join("\n");
|
||
}
|
||
return text
|
||
.replace(/\r\n/g, "\n")
|
||
.replace(/[ \t]+\n/g, "\n")
|
||
.replace(/\n{3,}/g, "\n\n")
|
||
.trim();
|
||
}
|
||
|
||
function compactReferenceText(text: string): string {
|
||
return text.replace(/\s+/g, " ").trim();
|
||
}
|
||
|
||
function segmentReferenceText(text: string): string[] {
|
||
const explicit = text
|
||
.split(/\n{2,}/)
|
||
.map((item) => item.trim())
|
||
.filter(Boolean);
|
||
const base = explicit.length > 1 ? explicit : splitLongParagraph(text);
|
||
return base.slice(0, 80);
|
||
}
|
||
|
||
function splitLongParagraph(text: string): string[] {
|
||
const normalized = text
|
||
.split(/\n+/)
|
||
.map((item) => item.trim())
|
||
.filter(Boolean)
|
||
.join("\n");
|
||
if (normalized.length <= 360) {
|
||
return normalized ? [normalized] : [];
|
||
}
|
||
|
||
const sentences = normalized
|
||
.split(/(?<=[。!?!?;;])\s*/)
|
||
.map((item) => item.trim())
|
||
.filter(Boolean);
|
||
if (sentences.length <= 1) {
|
||
return normalized.match(/[\s\S]{1,360}/g) ?? [];
|
||
}
|
||
|
||
const paragraphs: string[] = [];
|
||
let current = "";
|
||
for (const sentence of sentences) {
|
||
if ((current + sentence).length > 360 && current) {
|
||
paragraphs.push(current.trim());
|
||
current = "";
|
||
}
|
||
current += sentence;
|
||
}
|
||
if (current.trim()) {
|
||
paragraphs.push(current.trim());
|
||
}
|
||
return paragraphs;
|
||
}
|