414 lines
15 KiB
TypeScript
414 lines
15 KiB
TypeScript
import { BookOpenText, ChevronRight, FileText } from "lucide-react";
|
|
import { useMemo, useState } from "react";
|
|
|
|
import { Badge } from "@/components/ui/badge";
|
|
import {
|
|
Sheet,
|
|
SheetContent,
|
|
SheetDescription,
|
|
SheetHeader,
|
|
SheetTitle,
|
|
} from "@/components/ui/sheet";
|
|
import type { KnowledgeWikiReferenceSelection } from "@/core/llmwiki/knowledge-reference";
|
|
import {
|
|
classifyKnowledgeWikiSource,
|
|
filterKnowledgeWikiSources,
|
|
} from "@/core/llmwiki/knowledge-reference";
|
|
import {
|
|
formatRetrievalScore,
|
|
retrievalModeLabel,
|
|
retrievalRankScoreLabel,
|
|
} from "@/core/llmwiki/retrieval-debug";
|
|
import { resolveWeKnoraSourceTarget } from "@/core/llmwiki/source-links";
|
|
import type { RuntimeDisplaySource } from "@/core/skill-display/runtime";
|
|
import { reasoningPlugins } from "@/core/streamdown";
|
|
import { cn } from "@/lib/utils";
|
|
import { WeKnoraSourceDrawer } from "@/strategy-components/components/resource-management/llmwiki/WeKnoraSourceDrawer";
|
|
|
|
import { MarkdownContent } from "./messages/markdown-content";
|
|
|
|
type KnowledgeWikiPanelProps = {
|
|
highlightedReferenceId?: string | null;
|
|
selection: KnowledgeWikiReferenceSelection;
|
|
sources: RuntimeDisplaySource[];
|
|
};
|
|
|
|
function sourceKey(source: RuntimeDisplaySource) {
|
|
return `${source.skillName}:${source.index}:${source.id ?? source.url ?? source.title}`;
|
|
}
|
|
|
|
function stringValue(value: unknown): string {
|
|
return typeof value === "string" ? value.trim() : "";
|
|
}
|
|
|
|
function sourceContent(source: RuntimeDisplaySource) {
|
|
return (source.content || source.snippet || "").trim();
|
|
}
|
|
|
|
function sourceMode(source: RuntimeDisplaySource) {
|
|
return stringValue(
|
|
source.raw?.retrieval_mode ??
|
|
(source as RuntimeDisplaySource & { retrieval_mode?: unknown })
|
|
.retrieval_mode,
|
|
).toLowerCase();
|
|
}
|
|
|
|
function matchedHeading(source: RuntimeDisplaySource) {
|
|
const sections = Array.isArray(source.raw?.matched_sections)
|
|
? source.raw.matched_sections
|
|
: [];
|
|
const firstSectionWithHeading = sections.find(
|
|
(section): section is Record<string, unknown> =>
|
|
!!section &&
|
|
typeof section === "object" &&
|
|
!Array.isArray(section) &&
|
|
Boolean(stringValue((section as Record<string, unknown>).heading)),
|
|
);
|
|
const matchedSection =
|
|
source.raw?.matched_section &&
|
|
typeof source.raw.matched_section === "object" &&
|
|
!Array.isArray(source.raw.matched_section)
|
|
? (source.raw.matched_section as Record<string, unknown>)
|
|
: null;
|
|
return stringValue(
|
|
source.raw?.matched_heading ??
|
|
source.raw?.heading ??
|
|
firstSectionWithHeading?.heading ??
|
|
matchedSection?.heading,
|
|
);
|
|
}
|
|
|
|
function knowledgeSpaceName(source: RuntimeDisplaySource) {
|
|
return (
|
|
stringValue(source.raw?.knowledge_base_name) ||
|
|
stringValue(source.raw?.base_name) ||
|
|
stringValue(source.raw?.source) ||
|
|
source.source ||
|
|
"知识空间"
|
|
);
|
|
}
|
|
|
|
function wikiPageTypeLabel(value: unknown) {
|
|
const normalized = stringValue(value).toLowerCase();
|
|
if (normalized === "directory") return "目录页";
|
|
if (normalized === "overview") return "概览页";
|
|
if (normalized === "article") return "文章页";
|
|
return normalized ? stringValue(value) : "";
|
|
}
|
|
|
|
function plainTextPreview(value: string) {
|
|
return value
|
|
.replace(/^---[\s\S]*?---\s*/m, "")
|
|
.replace(/```[\s\S]*?```/g, " ")
|
|
.replace(/!\[([^\]]*)\]\([^)]*\)/g, "$1")
|
|
.replace(/\[([^\]]+)\]\([^)]*\)/g, "$1")
|
|
.replace(/\[\[[^\]\s|]+[|\s]+([^\]]+)\]\]/g, "$1")
|
|
.replace(/\[\[([^\]]+)\]\]/g, "$1")
|
|
.replace(/^#{1,6}\s+/gm, "")
|
|
.replace(/^\s*[-*+]\s+/gm, "")
|
|
.replace(/^\s*\d+[.)]\s+/gm, "")
|
|
.replace(/[>*_`~|]/g, " ")
|
|
.replace(/\s+/g, " ")
|
|
.trim();
|
|
}
|
|
|
|
function cardPreview(source: RuntimeDisplaySource) {
|
|
const summary = stringValue(source.raw?.summary);
|
|
return plainTextPreview(sourceContent(source) || summary).slice(0, 420);
|
|
}
|
|
|
|
function updatedAtLabel(source: RuntimeDisplaySource) {
|
|
const value =
|
|
source.time ||
|
|
stringValue(source.raw?.updated_at) ||
|
|
stringValue(source.raw?.remote_updated_at);
|
|
if (!value) return "";
|
|
const date = new Date(value);
|
|
return Number.isNaN(date.getTime())
|
|
? value
|
|
: date.toLocaleString("zh-CN", { hour12: false });
|
|
}
|
|
|
|
function scoreLabel(source: RuntimeDisplaySource) {
|
|
const score = formatRetrievalScore(source.score);
|
|
if (!score) return "";
|
|
const label = retrievalRankScoreLabel(
|
|
source.raw?.rank_score_semantics,
|
|
Boolean(source.raw?.channel_score || source.raw?.vector_score),
|
|
);
|
|
return label ? `${label} ${score}` : "";
|
|
}
|
|
|
|
/**
|
|
* Knowledge-space references keep both evidence layers visible: formatted Wiki
|
|
* pages and their original document chunks. They deliberately use dedicated
|
|
* Wiki cards instead of the generic numbered reference-card presentation.
|
|
*/
|
|
export function KnowledgeWikiPanel({
|
|
highlightedReferenceId,
|
|
selection,
|
|
sources,
|
|
}: KnowledgeWikiPanelProps) {
|
|
const wikiSources = useMemo(
|
|
() => filterKnowledgeWikiSources(sources, selection),
|
|
[selection, sources],
|
|
);
|
|
const orderedSources = useMemo(() => {
|
|
const formattedWiki = wikiSources.filter(
|
|
(source) => classifyKnowledgeWikiSource(source) === "wiki",
|
|
);
|
|
const rawDocuments = wikiSources.filter(
|
|
(source) => classifyKnowledgeWikiSource(source) === "raw",
|
|
);
|
|
return [...formattedWiki, ...rawDocuments];
|
|
}, [wikiSources]);
|
|
const [openSourceKey, setOpenSourceKey] = useState<string | null>(null);
|
|
const openSource =
|
|
wikiSources.find((source) => sourceKey(source) === openSourceKey) ?? null;
|
|
const previewTarget = openSource
|
|
? resolveWeKnoraSourceTarget({
|
|
...(openSource.raw ?? {}),
|
|
url: openSource.url,
|
|
})
|
|
: null;
|
|
const openContent = openSource ? sourceContent(openSource) : "";
|
|
const selectedSpaceCount =
|
|
selection.knowledgeBaseIds.length +
|
|
selection.assistantKnowledgeBaseIds.length;
|
|
const formattedWikiCount = wikiSources.filter(
|
|
(source) => classifyKnowledgeWikiSource(source) === "wiki",
|
|
).length;
|
|
const rawDocumentCount = wikiSources.length - formattedWikiCount;
|
|
|
|
return (
|
|
<div className="flex size-full min-h-0 flex-col overflow-hidden">
|
|
<header className="shrink-0 border-b border-emerald-200/60 px-1 pb-3 dark:border-emerald-900/50">
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
<div className="flex size-8 items-center justify-center rounded-lg bg-emerald-600 text-white shadow-sm shadow-emerald-600/20">
|
|
<BookOpenText className="size-4" />
|
|
</div>
|
|
<div className="min-w-0 flex-1">
|
|
<div className="flex flex-wrap items-baseline gap-2">
|
|
<h2 className="text-foreground text-base font-semibold tracking-tight">
|
|
Wiki 资料
|
|
</h2>
|
|
<span className="text-muted-foreground text-xs tabular-nums">
|
|
{wikiSources.length} 条
|
|
</span>
|
|
</div>
|
|
<p className="text-muted-foreground mt-0.5 text-xs leading-relaxed">
|
|
来自已选 {selectedSpaceCount} 个知识空间,点击卡片查看完整内容。
|
|
</p>
|
|
</div>
|
|
</div>
|
|
{wikiSources.length > 0 && (
|
|
<div className="mt-2.5 flex flex-wrap gap-1.5">
|
|
<Badge className="border-emerald-200 bg-emerald-100 text-emerald-800 hover:bg-emerald-100 dark:border-emerald-800 dark:bg-emerald-950 dark:text-emerald-200">
|
|
Wiki 页面 {formattedWikiCount}
|
|
</Badge>
|
|
<Badge variant="outline" className="bg-background/80">
|
|
原始资料 {rawDocumentCount}
|
|
</Badge>
|
|
</div>
|
|
)}
|
|
</header>
|
|
|
|
<main className="min-h-0 flex-1 overflow-y-auto px-1 pt-3 pb-6">
|
|
{orderedSources.length > 0 ? (
|
|
<div className="grid gap-3">
|
|
{orderedSources.map((source) => (
|
|
<KnowledgeWikiCard
|
|
key={sourceKey(source)}
|
|
highlighted={highlightedReferenceId === String(source.index)}
|
|
source={source}
|
|
onOpen={() => setOpenSourceKey(sourceKey(source))}
|
|
/>
|
|
))}
|
|
</div>
|
|
) : (
|
|
<div className="text-muted-foreground flex min-h-48 flex-col items-center justify-center rounded-xl border border-dashed bg-muted/20 px-6 text-center text-sm">
|
|
<BookOpenText className="mb-3 size-8 opacity-50" />
|
|
<span>本轮未检索到所选知识空间内的 Wiki 资料。</span>
|
|
</div>
|
|
)}
|
|
</main>
|
|
|
|
{openSource && previewTarget && (
|
|
<WeKnoraSourceDrawer
|
|
open
|
|
target={previewTarget}
|
|
title={openSource.title}
|
|
quote={openContent}
|
|
onOpenChange={(open) => {
|
|
if (!open) setOpenSourceKey(null);
|
|
}}
|
|
/>
|
|
)}
|
|
|
|
<Sheet
|
|
open={Boolean(openSource && !previewTarget)}
|
|
onOpenChange={(open) => {
|
|
if (!open) setOpenSourceKey(null);
|
|
}}
|
|
>
|
|
<SheetContent
|
|
side="right"
|
|
className="w-[min(92vw,760px)] gap-0 overflow-hidden p-0 sm:max-w-none"
|
|
>
|
|
<SheetHeader className="shrink-0 border-b border-emerald-200/60 bg-emerald-50/60 px-5 py-4 pr-12 text-left dark:border-emerald-900/50 dark:bg-emerald-950/20">
|
|
<div className="flex items-start gap-3 pr-8">
|
|
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-emerald-600 text-white">
|
|
<BookOpenText className="size-4.5" />
|
|
</div>
|
|
<div className="min-w-0">
|
|
<SheetTitle className="text-base leading-relaxed">
|
|
{openSource?.title || "Wiki 内容"}
|
|
</SheetTitle>
|
|
<SheetDescription className="mt-1">
|
|
{openSource ? knowledgeSpaceName(openSource) : "知识空间"}
|
|
</SheetDescription>
|
|
</div>
|
|
</div>
|
|
</SheetHeader>
|
|
<div className="min-h-0 flex-1 overflow-y-auto bg-slate-50/70 p-4 dark:bg-slate-950/40 sm:p-6">
|
|
{openContent ? (
|
|
<article className="mx-auto max-w-3xl rounded-xl bg-background px-4 py-5 shadow-sm ring-1 ring-border/70 sm:px-7">
|
|
<MarkdownContent
|
|
className="max-w-none"
|
|
content={openContent}
|
|
isLoading={false}
|
|
remarkPlugins={reasoningPlugins.remarkPlugins}
|
|
rehypePlugins={reasoningPlugins.rehypePlugins}
|
|
/>
|
|
</article>
|
|
) : (
|
|
<div className="text-muted-foreground flex min-h-64 items-center justify-center rounded-xl border border-dashed bg-background text-sm">
|
|
当前 Wiki 暂无可展示内容。
|
|
</div>
|
|
)}
|
|
</div>
|
|
</SheetContent>
|
|
</Sheet>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function KnowledgeWikiCard({
|
|
highlighted,
|
|
source,
|
|
onOpen,
|
|
}: {
|
|
highlighted: boolean;
|
|
source: RuntimeDisplaySource;
|
|
onOpen: () => void;
|
|
}) {
|
|
const formattedWiki = classifyKnowledgeWikiSource(source) === "wiki";
|
|
const heading = matchedHeading(source);
|
|
const pageType = wikiPageTypeLabel(source.raw?.page_type);
|
|
const mode = sourceMode(source);
|
|
const modeLabel = mode ? retrievalModeLabel(mode) : "";
|
|
const preview = cardPreview(source);
|
|
const score = scoreLabel(source);
|
|
const updatedAt = updatedAtLabel(source);
|
|
const target = resolveWeKnoraSourceTarget({
|
|
...(source.raw ?? {}),
|
|
url: source.url,
|
|
});
|
|
const canOpen = Boolean(target || sourceContent(source));
|
|
|
|
return (
|
|
<button
|
|
id={`reference-${source.index}`}
|
|
type="button"
|
|
disabled={!canOpen}
|
|
className={cn(
|
|
"group w-full scroll-mt-12 rounded-xl border bg-background/90 p-4 text-left shadow-sm transition",
|
|
"focus-visible:ring-2 focus-visible:outline-none disabled:cursor-default disabled:opacity-70",
|
|
formattedWiki
|
|
? "border-emerald-200/80 hover:-translate-y-0.5 hover:border-emerald-400 hover:shadow-md focus-visible:ring-emerald-500/40 dark:border-emerald-900/70"
|
|
: "border-sky-200/80 hover:-translate-y-0.5 hover:border-sky-400 hover:shadow-md focus-visible:ring-sky-500/40 dark:border-sky-900/70",
|
|
highlighted &&
|
|
(formattedWiki
|
|
? "border-emerald-500 bg-emerald-50/80 ring-2 ring-emerald-500/20 dark:bg-emerald-950/25"
|
|
: "border-sky-500 bg-sky-50/80 ring-2 ring-sky-500/20 dark:bg-sky-950/25"),
|
|
)}
|
|
onClick={onOpen}
|
|
>
|
|
<div className="flex items-start gap-3">
|
|
<div
|
|
className={cn(
|
|
"mt-0.5 flex size-9 shrink-0 items-center justify-center rounded-lg",
|
|
formattedWiki
|
|
? "bg-emerald-100 text-emerald-700 dark:bg-emerald-950 dark:text-emerald-300"
|
|
: "bg-sky-100 text-sky-700 dark:bg-sky-950 dark:text-sky-300",
|
|
)}
|
|
>
|
|
{formattedWiki ? (
|
|
<BookOpenText className="size-4.5" />
|
|
) : (
|
|
<FileText className="size-4.5" />
|
|
)}
|
|
</div>
|
|
<div className="min-w-0 flex-1">
|
|
<div className="flex flex-wrap items-center gap-1.5">
|
|
<Badge
|
|
variant="secondary"
|
|
className={cn(
|
|
formattedWiki
|
|
? "bg-emerald-50 text-emerald-700 dark:bg-emerald-950 dark:text-emerald-300"
|
|
: "bg-sky-50 text-sky-700 dark:bg-sky-950 dark:text-sky-300",
|
|
)}
|
|
>
|
|
{formattedWiki ? "Wiki 页面" : "原始资料"}
|
|
</Badge>
|
|
{formattedWiki && pageType && (
|
|
<Badge variant="outline">{pageType}</Badge>
|
|
)}
|
|
{modeLabel && <Badge variant="outline">{modeLabel}</Badge>}
|
|
<span className="text-muted-foreground min-w-0 truncate text-xs">
|
|
{knowledgeSpaceName(source)}
|
|
</span>
|
|
</div>
|
|
<h3 className="text-foreground mt-2 line-clamp-2 text-[15px] leading-6 font-semibold">
|
|
{source.title ||
|
|
(formattedWiki ? "未命名 Wiki 页面" : "未命名原始资料")}
|
|
</h3>
|
|
{heading && (
|
|
<div className="mt-1 text-xs font-medium text-emerald-700 dark:text-emerald-300">
|
|
命中章节:{heading}
|
|
</div>
|
|
)}
|
|
{preview && (
|
|
<p className="text-muted-foreground mt-2 line-clamp-3 whitespace-pre-wrap text-sm leading-6">
|
|
{preview}
|
|
</p>
|
|
)}
|
|
<div className="text-muted-foreground mt-3 flex flex-wrap items-center justify-between gap-2 border-t pt-3 text-xs">
|
|
<div className="flex flex-wrap items-center gap-x-3 gap-y-1">
|
|
{score && <span>{score}</span>}
|
|
{updatedAt && <span>更新于 {updatedAt}</span>}
|
|
</div>
|
|
{canOpen && (
|
|
<span
|
|
className={cn(
|
|
"inline-flex items-center font-medium transition group-hover:translate-x-0.5",
|
|
formattedWiki
|
|
? "text-emerald-700 dark:text-emerald-300"
|
|
: "text-sky-700 dark:text-sky-300",
|
|
)}
|
|
>
|
|
{target
|
|
? formattedWiki
|
|
? "查看完整 Wiki"
|
|
: "查看原始资料"
|
|
: "查看命中内容"}
|
|
<ChevronRight className="ml-1 size-3.5" />
|
|
</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</button>
|
|
);
|
|
}
|