代码提交

This commit is contained in:
董宏凯 committed 2026-07-15 16:13:03 +08:00
1 parent 27e6b0b23e
commit d7ad0624b7
36 files changed
+2194 -287

No files matched your search

@@ -1,4 +1,4 @@
import { ExternalLinkIcon } from "lucide-react";
import { ExternalLinkIcon, FileTextIcon, LoaderCircle } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
@@ -9,10 +9,12 @@ import {
DialogTitle,
} from "@/components/ui/dialog";
import type { RuntimeDisplaySource } from "@/core/skill-display/runtime";
import { apiFetch } from "@/core/api/fetch-client";
import { getBackendBaseURL } from "@/core/config";
import { useLocalSettings } from "@/core/settings";
import { useCitationDisplaySettings } from "@/core/system-settings/hooks";
import { cn } from "@/lib/utils";
import { useState } from "react";
import { useEffect, useState } from "react";
export function SkillDisplaySources({
highlightedReferenceId,
@@ -51,6 +53,9 @@ export function SourceCard({
referenceTarget?: boolean;
}) {
const [open, setOpen] = useState(false);
const [context, setContext] = useState<WeKnoraSourceContext | null>(null);
const [contextLoading, setContextLoading] = useState(false);
const [contextError, setContextError] = useState<string | null>(null);
const [settings] = useLocalSettings();
const { data: citationDisplay } = useCitationDisplaySettings();
const cleanupWords =
@@ -73,6 +78,44 @@ export function SourceCard({
].filter(Boolean);
const isCitation = source.mode === "citation";
const showReferenceNumber = isCitation;
const previewTarget = parseWeKnoraPreviewUrl(source.url);
useEffect(() => {
if (!open || !previewTarget) {
return;
}
let cancelled = false;
setContext(null);
setContextError(null);
setContextLoading(true);
const params = new URLSearchParams({
knowledge_base_id: previewTarget.knowledgeBaseId,
chunk_id: previewTarget.chunkId,
});
void apiFetch(`${getBackendBaseURL()}/api/weknora/source-context?${params}`)
.then(async (response) => {
const payload = (await response.json().catch(() => null)) as unknown;
if (!response.ok || !isWeKnoraSourceContextResponse(payload)) {
throw new Error(readPreviewError(payload, response.status));
}
if (!cancelled) {
setContext(payload.data);
}
})
.catch((error: unknown) => {
if (!cancelled) {
setContextError(error instanceof Error ? error.message : "无法加载原文上下文");
}
})
.finally(() => {
if (!cancelled) {
setContextLoading(false);
}
});
return () => {
cancelled = true;
};
}, [open, previewTarget?.chunkId, previewTarget?.knowledgeBaseId]);
return (
<>
@@ -114,7 +157,14 @@ export function SourceCard({
)}
</div>
{source.url && (
<ExternalLinkIcon className="text-muted-foreground/60 group-hover:text-primary mt-1 size-3.5 shrink-0 transition-colors" />
previewTarget ? (
<span className="text-primary bg-primary/10 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>
</button>
@@ -141,7 +191,7 @@ export function SourceCard({
</DialogDescription>
)}
</div>
{source.url && (
{source.url && !previewTarget && (
<Button size="icon-sm" variant="outline" asChild>
<a
href={source.url}
@@ -155,7 +205,13 @@ export function SourceCard({
)}
</div>
</DialogHeader>
{paragraphs.length > 0 && (
{previewTarget ? (
<WeKnoraSourceContextPanel
context={context}
loading={contextLoading}
error={contextError}
/>
) : 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">
@@ -163,8 +219,8 @@ export function SourceCard({
</p>
))}
</div>
)}
{source.url && (
) : null}
{source.url && !previewTarget && (
<div className="text-muted-foreground border-t pt-3 text-xs break-all">
{source.url}
</div>
@@ -175,6 +231,118 @@ export function SourceCard({
);
}
type WeKnoraPreviewTarget = {
knowledgeBaseId: string;
chunkId: string;
};
type WeKnoraSourceContext = {
knowledge_base_name?: string;
document_title?: string;
document_file_name?: string;
focus_chunk_id: string;
chunks: Array<{
id: string;
chunk_index?: number;
content: string;
is_focus: boolean;
}>;
};
function parseWeKnoraPreviewUrl(url?: string): WeKnoraPreviewTarget | null {
if (!url?.startsWith("/weknora-source-preview?")) {
return null;
}
const queryIndex = url.indexOf("?");
const params = new URLSearchParams(url.slice(queryIndex + 1));
const knowledgeBaseId = params.get("knowledge_base_id")?.trim();
const chunkId = params.get("chunk_id")?.trim();
return knowledgeBaseId && chunkId ? { knowledgeBaseId, chunkId } : null;
}
function isWeKnoraSourceContextResponse(
value: unknown,
): value is { success: true; data: WeKnoraSourceContext } {
if (!value || typeof value !== "object") {
return false;
}
const data = (value as { data?: unknown }).data;
return (
(value as { success?: unknown }).success === true &&
!!data &&
typeof data === "object" &&
Array.isArray((data as WeKnoraSourceContext).chunks)
);
}
function readPreviewError(payload: unknown, status: number): string {
if (payload && typeof payload === "object") {
const detail = (payload as { detail?: unknown }).detail;
if (typeof detail === "string" && detail) {
return detail;
}
}
return status === 404 ? "原文已不可用" : "无法加载原文上下文";
}
function WeKnoraSourceContextPanel({
context,
loading,
error,
}: {
context: WeKnoraSourceContext | null;
loading: boolean;
error: string | null;
}) {
if (loading) {
return (
<div className="text-muted-foreground flex min-h-36 items-center justify-center gap-2 text-sm">
<LoaderCircle className="size-4 animate-spin" />
正在加载原文上下文…
</div>
);
}
if (error) {
return (
<div className="border-destructive/30 bg-destructive/5 text-destructive rounded-lg border p-3 text-sm">
{error}
</div>
);
}
if (!context) {
return null;
}
return (
<div className="max-h-[55vh] space-y-3 overflow-y-auto pr-1">
<div className="bg-muted/60 text-muted-foreground rounded-lg px-3 py-2 text-xs">
<span>{context.knowledge_base_name || "WeKnora 知识库"}</span>
{context.document_title && <span> · {context.document_title}</span>}
</div>
{context.chunks.map((chunk) => (
<section
key={chunk.id}
className={cn(
"rounded-lg border p-3 text-sm leading-relaxed",
chunk.is_focus
? "border-primary/50 bg-primary/5 shadow-sm"
: "bg-muted/20",
)}
>
<div className="text-muted-foreground mb-2 flex items-center gap-2 text-xs">
<span>分段 {typeof chunk.chunk_index === "number" ? chunk.chunk_index + 1 : "-"}</span>
{chunk.is_focus && (
<span className="bg-primary text-primary-foreground rounded px-1.5 py-0.5 font-medium">
检索命中
</span>
)}
</div>
<p className="whitespace-pre-wrap">{chunk.content}</p>
</section>
))}
</div>
);
}
function cleanReferenceText(
raw: string | undefined,
cleanupWords: string[],