调整目录

This commit is contained in:
mlogclub
2026-04-24 12:01:30 +08:00
parent 7889ecb0d2
commit a9275c2d4a
209 changed files with 0 additions and 0 deletions
@@ -0,0 +1,225 @@
"use client";
import { useState } from "react";
import { BotIcon, SearchIcon, SparklesIcon } from "lucide-react";
import { toast } from "sonner";
import {
debugKnowledgeAnswer,
debugKnowledgeSearch,
type KnowledgeAnswerResponse,
type KnowledgeSearchResponse,
} from "@/lib/api/admin";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Textarea } from "@/components/ui/textarea";
type DebugPanelProps = {
knowledgeBaseId: number | null;
};
export function DebugPanel({ knowledgeBaseId }: DebugPanelProps) {
const [question, setQuestion] = useState("");
const [topK, setTopK] = useState("5");
const [scoreThreshold, setScoreThreshold] = useState("0.2");
const [rerankLimit, setRerankLimit] = useState("5");
const [searching, setSearching] = useState(false);
const [answering, setAnswering] = useState(false);
const [searchResult, setSearchResult] = useState<KnowledgeSearchResponse | null>(null);
const [answerResult, setAnswerResult] = useState<KnowledgeAnswerResponse | null>(null);
async function handleSearch() {
if (!knowledgeBaseId) {
toast.error("请先选择知识库");
return;
}
if (!question.trim()) {
toast.error("请输入调试问题");
return;
}
setSearching(true);
try {
const data = await debugKnowledgeSearch({
knowledgeBaseIds: [knowledgeBaseId],
question: question.trim(),
topK: Number(topK) || undefined,
scoreThreshold: Number(scoreThreshold) || undefined,
rerankLimit: Number(rerankLimit) || undefined,
});
setSearchResult(data);
toast.success(`检索完成,命中 ${data.hitCount}`);
} catch (error) {
toast.error(error instanceof Error ? error.message : "检索失败");
} finally {
setSearching(false);
}
}
async function handleAnswer() {
if (!knowledgeBaseId) {
toast.error("请先选择知识库");
return;
}
if (!question.trim()) {
toast.error("请输入调试问题");
return;
}
setAnswering(true);
try {
const data = await debugKnowledgeAnswer({
knowledgeBaseIds: [knowledgeBaseId],
question: question.trim(),
topK: Number(topK) || undefined,
scoreThreshold: Number(scoreThreshold) || undefined,
rerankLimit: Number(rerankLimit) || undefined,
});
setAnswerResult(data);
toast.success(`问答完成,状态:${data.answerStatusName}`);
} catch (error) {
toast.error(error instanceof Error ? error.message : "问答失败");
} finally {
setAnswering(false);
}
}
return (
<div className="flex h-full flex-col gap-3 p-3">
<div className="space-y-3">
<Textarea
value={question}
onChange={(event) => setQuestion(event.target.value)}
placeholder="输入问题,测试知识库召回和回答效果"
rows={5}
className="text-sm"
/>
<div className="grid grid-cols-3 gap-3">
<div className="space-y-1.5">
<Label htmlFor="topk" className="text-xs">TopK</Label>
<Input id="topk" value={topK} onChange={(event) => setTopK(event.target.value)} placeholder="召回数量" className="h-8" />
</div>
<div className="space-y-1.5">
<Label htmlFor="threshold" className="text-xs"></Label>
<Input id="threshold" value={scoreThreshold} onChange={(event) => setScoreThreshold(event.target.value)} placeholder="最低分数" className="h-8" />
</div>
<div className="space-y-1.5">
<Label htmlFor="rerank" className="text-xs"></Label>
<Input id="rerank" value={rerankLimit} onChange={(event) => setRerankLimit(event.target.value)} placeholder="重排条数" className="h-8" />
</div>
</div>
<div className="flex gap-2">
<Button className="flex-1" variant="outline" onClick={() => void handleSearch()} disabled={searching}>
<SearchIcon className="mr-2 size-4" />
{searching ? "检索中..." : "调试检索"}
</Button>
<Button className="flex-1" onClick={() => void handleAnswer()} disabled={answering}>
<SparklesIcon className="mr-2 size-4" />
{answering ? "生成中..." : "调试问答"}
</Button>
</div>
</div>
<ScrollArea className="min-h-0 flex-1">
<div className="space-y-3">
{answerResult ? (
<Card>
<CardHeader className="pb-2">
<CardTitle className="flex items-center gap-2 text-sm">
<BotIcon className="size-4" />
</CardTitle>
</CardHeader>
<CardContent className="space-y-3 text-sm">
<div className="flex flex-wrap items-center gap-2">
<Badge variant="secondary">{answerResult.answerStatusName}</Badge>
<span className="text-xs text-muted-foreground">
{answerResult.latencyMs}ms · {answerResult.modelName || "fallback"}
</span>
</div>
<div className="rounded-md border bg-background p-3 whitespace-pre-wrap">
{answerResult.answer}
</div>
{answerResult.citations.length > 0 ? (
<div className="space-y-2">
<div className="text-xs font-medium text-muted-foreground"></div>
{answerResult.citations.map((citation) => (
<div
key={`${citation.documentId}-${citation.chunkNo}-${citation.sectionPath}`}
className="rounded-md border bg-muted/30 p-3"
>
<div className="flex items-center justify-between gap-2">
<div className="truncate text-xs font-medium">
{getSearchResultLabel(citation)}
</div>
<Badge variant="outline">{citation.score.toFixed(4)}</Badge>
</div>
<div className="mt-1 text-xs text-muted-foreground">
{citation.sectionPath || citation.title || `Chunk #${citation.chunkNo}`}
</div>
<div className="mt-2 text-xs leading-5 text-muted-foreground whitespace-pre-wrap">
{citation.snippet}
</div>
</div>
))}
</div>
) : null}
</CardContent>
</Card>
) : null}
{searchResult ? (
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm"></CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<div className="text-xs text-muted-foreground">
{searchResult.hitCount} · {searchResult.latencyMs}ms
</div>
{searchResult.results.map((item) => (
<div key={`${item.chunkId}-${item.documentId}`} className="rounded-md border bg-background p-3">
<div className="flex items-center justify-between gap-2">
<div className="truncate text-sm font-medium">
{getSearchResultLabel(item)}
</div>
<Badge variant="outline">{item.score.toFixed(4)}</Badge>
</div>
<div className="mt-1 text-xs text-muted-foreground">
{item.sectionPath || item.title || `Chunk #${item.chunkNo}`}
</div>
<div className="mt-2 text-xs leading-5 text-muted-foreground whitespace-pre-wrap">
{item.content}
</div>
</div>
))}
</CardContent>
</Card>
) : null}
</div>
</ScrollArea>
</div>
);
}
function getSearchResultLabel(item: {
faqQuestion?: string
faqId?: number
documentTitle?: string
documentId?: number
}) {
if (item.faqQuestion) {
return item.faqQuestion
}
if (item.documentTitle) {
return item.documentTitle
}
if (item.faqId && item.faqId > 0) {
return `FAQ ${item.faqId}`
}
return `文档 ${item.documentId ?? 0}`
}
@@ -0,0 +1,239 @@
"use client"
import { useEffect, useState } from "react"
import { zodResolver } from "@hookform/resolvers/zod"
import { Controller, Resolver, useForm } from "react-hook-form"
import { z } from "zod/v4"
import { ProjectDialog } from "@/components/project-dialog"
import { ContentEditor } from "@/components/content-editor"
import { Button } from "@/components/ui/button"
import {
Field,
FieldContent,
FieldError,
FieldLabel,
} from "@/components/ui/field"
import { Input } from "@/components/ui/input"
import {
type KnowledgeDocument,
type CreateKnowledgeDocumentPayload,
fetchKnowledgeDocument,
} from "@/lib/api/admin"
import {
KnowledgeDocumentContentType,
} from "@/lib/generated/enums"
type DocumentEditDialogProps = {
open: boolean
saving: boolean
itemId: number | null
knowledgeBaseId: number | null
onOpenChange: (open: boolean) => void
onSubmit: (payload: CreateKnowledgeDocumentPayload) => Promise<void>
}
const emptyForm: EditForm = {
title: "",
contentType: KnowledgeDocumentContentType.Markdown,
content: "",
}
const knowledgeDocumentFormSchema = z.object({
title: z.string().trim().min(1, "标题不能为空").max(255, "标题最多255个字符"),
contentType: z.string().trim().min(1, "请选择内容类型"),
content: z.string().trim().min(1, "内容不能为空"),
})
type EditForm = z.infer<typeof knowledgeDocumentFormSchema>
const editFormResolver = zodResolver(knowledgeDocumentFormSchema as never) as Resolver<
z.input<typeof knowledgeDocumentFormSchema>,
undefined,
z.output<typeof knowledgeDocumentFormSchema>
>
function buildForm(item: KnowledgeDocument | null): EditForm {
if (!item) {
return emptyForm
}
return {
title: item.title,
contentType: item.contentType || KnowledgeDocumentContentType.Markdown,
content: item.content || "",
}
}
function buildPayload(form: EditForm, knowledgeBaseId: number): CreateKnowledgeDocumentPayload {
return {
knowledgeBaseId,
title: form.title.trim(),
contentType: form.contentType,
content: form.content.trim(),
}
}
export function DocumentEditDialog({
open,
saving,
itemId,
knowledgeBaseId,
onOpenChange,
onSubmit,
}: DocumentEditDialogProps) {
if (!open || !knowledgeBaseId) {
return null
}
return (
<DocumentFormDialogBody
key={itemId ? `edit-${itemId}` : "create"}
itemId={itemId}
knowledgeBaseId={knowledgeBaseId}
saving={saving}
onOpenChange={onOpenChange}
onSubmit={onSubmit}
/>
)
}
type DocumentFormDialogBodyProps = {
saving: boolean
itemId: number | null
knowledgeBaseId: number
onOpenChange: (open: boolean) => void
onSubmit: (payload: CreateKnowledgeDocumentPayload) => Promise<void>
}
function DocumentFormDialogBody({
saving,
itemId,
knowledgeBaseId,
onOpenChange,
onSubmit,
}: DocumentFormDialogBodyProps) {
const formId = "knowledge-document-edit-form"
const [loading, setLoading] = useState(false)
const form = useForm<
z.input<typeof knowledgeDocumentFormSchema>,
undefined,
z.output<typeof knowledgeDocumentFormSchema>
>({
resolver: editFormResolver,
defaultValues: emptyForm,
})
const {
control,
handleSubmit,
reset,
register,
setValue,
watch,
formState: { errors },
} = form
const contentType = watch("contentType")
const content = watch("content")
useEffect(() => {
async function loadDetail() {
if (!itemId) {
reset(emptyForm)
return
}
setLoading(true)
try {
const data = await fetchKnowledgeDocument(itemId)
reset(buildForm(data))
} catch (error) {
console.error("Failed to load knowledge document:", error)
} finally {
setLoading(false)
}
}
void loadDetail()
}, [itemId, reset])
async function onFormSubmit(values: EditForm) {
const payload = buildPayload({ ...values, contentType, content }, knowledgeBaseId)
await onSubmit(payload)
}
return (
<ProjectDialog
open={true}
onOpenChange={onOpenChange}
title={itemId ? "编辑文档" : "新建文档"}
size="xl"
allowFullscreen
footer={
<>
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
disabled={saving}
>
</Button>
<Button type="submit" form={formId} disabled={saving || loading}>
{saving ? "保存中..." : itemId ? "保存" : "创建"}
</Button>
</>
}
>
{loading ? (
<div className="flex items-center justify-center py-12">
<div className="text-muted-foreground">...</div>
</div>
) : (
<form id={formId} onSubmit={handleSubmit(onFormSubmit)} className="space-y-4">
<Field data-invalid={!!errors.title}>
<FieldLabel htmlFor="doc-title"></FieldLabel>
<FieldContent>
<Input
id="doc-title"
placeholder="文档标题"
aria-invalid={!!errors.title}
{...register("title")}
/>
<FieldError errors={[errors.title]} />
</FieldContent>
</Field>
<Field data-invalid={!!errors.content}>
<FieldLabel htmlFor="doc-content"></FieldLabel>
<FieldContent>
<Controller
control={control}
name="content"
render={({ field }) => (
<ContentEditor
value={{
mode:
contentType === KnowledgeDocumentContentType.HTML
? KnowledgeDocumentContentType.HTML
: KnowledgeDocumentContentType.Markdown,
raw: field.value ?? "",
}}
onChange={(next) => {
field.onChange(next.raw)
setValue("contentType", next.mode, {
shouldDirty: true,
shouldValidate: true,
})
}}
placeholder="请输入文档内容"
disabled={saving}
/>
)}
/>
<FieldError errors={[errors.content]} />
</FieldContent>
</Field>
</form>
)}
</ProjectDialog>
)
}
@@ -0,0 +1,565 @@
"use client";
import {
FileTextIcon,
MoreHorizontalIcon,
PencilIcon,
SearchIcon,
Trash2Icon,
WrenchIcon,
} from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuTrigger,
} from "@/components/ui/context-menu";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input";
import { ScrollArea } from "@/components/ui/scroll-area";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import {
buildKnowledgeDocumentIndex,
createKnowledgeDocument,
deleteKnowledgeDocument,
fetchKnowledgeDocuments,
updateKnowledgeDocument,
type CreateKnowledgeDocumentPayload,
type KnowledgeDocument,
type PageResult,
} from "@/lib/api/admin";
import { getEnumLabel, getEnumOptions } from "@/lib/enums";
import {
KnowledgeDocumentIndexStatus,
KnowledgeDocumentIndexStatusLabels,
StatusLabels
} from "@/lib/generated/enums";
import { cn, formatDateTime } from "@/lib/utils";
import { DocumentEditDialog } from "./document-edit";
type DocumentListProps = {
knowledgeBaseId: number | null;
onActionStateChange?: (state: DocumentListActionState) => void;
};
export type DocumentListActionState = {
onRefresh: () => void;
onChangeViewMode: (mode: "list" | "grid") => void;
onCreate: () => void;
viewMode: "list" | "grid";
loading: boolean;
};
const statusOptions = [
{ value: "all", label: "全部状态" },
...getEnumOptions(StatusLabels),
] as const;
const indexStatusOptions = [
{ value: "all", label: "全部索引状态" },
...getEnumOptions(KnowledgeDocumentIndexStatusLabels),
] as const;
function getIndexStatusBadgeVariant(status: string) {
switch (status) {
case KnowledgeDocumentIndexStatus.Indexed:
return "secondary" as const;
case KnowledgeDocumentIndexStatus.Failed:
return "destructive" as const;
default:
return "outline" as const;
}
}
function renderIndexStatusBadge(item: KnowledgeDocument) {
const badge = (
<Badge variant={getIndexStatusBadgeVariant(item.indexStatus)}>
{item.indexStatusName}
</Badge>
)
if (
item.indexStatus !== KnowledgeDocumentIndexStatus.Failed ||
!item.indexError
) {
return badge
}
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger>
<span className="inline-flex">{badge}</span>
</TooltipTrigger>
<TooltipContent align="start" className="max-w-sm whitespace-normal">
{item.indexError}
</TooltipContent>
</Tooltip>
</TooltipProvider>
)
}
function getDocumentPreview(content: string, contentType: string) {
const preview =
contentType === "markdown"
? content
.replace(/[`*_>#-]/g, " ")
.replace(/\[(.*?)\]\((.*?)\)/g, "$1")
.replace(/\s+/g, " ")
.trim()
: content
.replace(/<[^>]+>/g, " ")
.replace(/\s+/g, " ")
.trim();
return preview || "暂无内容";
}
const VIEW_MODE_STORAGE_KEY = "knowledge-document-view-mode";
export function DocumentList({ knowledgeBaseId, onActionStateChange }: DocumentListProps) {
const [keywordInput, setKeywordInput] = useState("");
const [statusFilterInput, setStatusFilterInput] = useState("all");
const [indexStatusFilterInput, setIndexStatusFilterInput] = useState("all");
const [keyword, setKeyword] = useState("");
const [statusFilter, setStatusFilter] = useState("all");
const [indexStatusFilter, setIndexStatusFilter] = useState("all");
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [actionLoadingMap, setActionLoadingMap] = useState<Record<number, { rebuildIndex: boolean; delete: boolean }>>({});
const [dialogOpen, setDialogOpen] = useState(false);
const [editingItem, setEditingItem] = useState<KnowledgeDocument | null>(
null,
);
const [viewMode, setViewMode] = useState<"list" | "grid">(() => {
if (typeof window === "undefined") return "grid";
const saved = localStorage.getItem(VIEW_MODE_STORAGE_KEY);
return saved === "list" || saved === "grid" ? saved : "grid";
});
const [documents, setDocuments] = useState<PageResult<KnowledgeDocument>>({
results: [],
page: { page: 1, limit: 20, total: 0 },
});
const loadData = useCallback(async () => {
if (!knowledgeBaseId) {
setDocuments({ results: [], page: { page: 1, limit: 20, total: 0 } });
setLoading(false);
return;
}
setLoading(true);
try {
const data = await fetchKnowledgeDocuments({
title: keyword.trim() || undefined,
status: statusFilter === "all" ? undefined : statusFilter,
indexStatus: indexStatusFilter === "all" ? undefined : indexStatusFilter,
knowledgeBaseId,
limit: 1000,
});
setDocuments(data);
} catch (error) {
toast.error(error instanceof Error ? error.message : "加载文档失败");
} finally {
setLoading(false);
}
}, [indexStatusFilter, keyword, statusFilter, knowledgeBaseId]);
useEffect(() => {
void loadData();
}, [loadData]);
useEffect(() => {
localStorage.setItem(VIEW_MODE_STORAGE_KEY, viewMode);
}, [viewMode]);
function handleStatusFilterChange(value: string | null) {
const newValue = value ?? "all";
setStatusFilterInput(newValue);
setStatusFilter(newValue);
}
function handleIndexStatusFilterChange(value: string | null) {
const newValue = value ?? "all";
setIndexStatusFilterInput(newValue);
setIndexStatusFilter(newValue);
}
function applyFilters() {
setKeyword(keywordInput);
setStatusFilter(statusFilterInput);
setIndexStatusFilter(indexStatusFilterInput);
}
function handleFilterKeyDown(event: React.KeyboardEvent<HTMLInputElement>) {
if (event.key !== "Enter") {
return;
}
event.preventDefault();
applyFilters();
}
const openCreateDialog = useCallback(() => {
setEditingItem(null);
setDialogOpen(true);
}, []);
useEffect(() => {
if (!onActionStateChange) {
return;
}
onActionStateChange({
onRefresh: () => void loadData(),
onChangeViewMode: setViewMode,
onCreate: openCreateDialog,
viewMode,
loading,
});
}, [onActionStateChange, loadData, openCreateDialog, viewMode, loading]);
function openEditDialog(item: KnowledgeDocument) {
setEditingItem(item);
setDialogOpen(true);
}
function handleDialogOpenChange(open: boolean) {
if (saving) {
return;
}
if (!open) {
setEditingItem(null);
}
setDialogOpen(open);
}
async function handleSubmit(payload: CreateKnowledgeDocumentPayload) {
if (saving) {
return;
}
setSaving(true);
try {
if (editingItem) {
await updateKnowledgeDocument({
id: editingItem.id,
...payload,
});
toast.success(`已更新文档:${editingItem.title}`);
} else {
await createKnowledgeDocument(payload);
toast.success(`已创建文档:${payload.title}`);
}
setDialogOpen(false);
setEditingItem(null);
await loadData();
} catch (error) {
toast.error(error instanceof Error ? error.message : "保存文档失败");
} finally {
setSaving(false);
}
}
async function handleDelete(item: KnowledgeDocument) {
setActionLoadingMap((prev) => ({ ...prev, [item.id]: { ...prev[item.id], delete: true } }));
try {
await deleteKnowledgeDocument(item.id);
toast.success(`已删除文档:${item.title}`);
await loadData();
} catch (error) {
toast.error(error instanceof Error ? error.message : "删除文档失败");
} finally {
setActionLoadingMap((prev) => ({ ...prev, [item.id]: { ...prev[item.id], delete: false } }));
}
}
async function handleBuildIndex(item: KnowledgeDocument) {
setActionLoadingMap((prev) => ({ ...prev, [item.id]: { ...prev[item.id], rebuildIndex: true } }));
try {
await buildKnowledgeDocumentIndex(item.id);
toast.success(`已重建索引:${item.title}`);
await loadData();
} catch (error) {
toast.error(error instanceof Error ? error.message : "重建索引失败");
} finally {
setActionLoadingMap((prev) => ({ ...prev, [item.id]: { ...prev[item.id], rebuildIndex: false } }));
}
}
if (!knowledgeBaseId) {
return (
<div className="flex h-full flex-col items-center justify-center text-muted-foreground">
<FileTextIcon className="mb-2 size-12 opacity-50" />
<p></p>
</div>
);
}
return (
<>
<div className="flex h-full min-h-0 flex-col">
<div className="flex flex-col gap-2 border-b bg-background px-6 py-2">
<div className="flex gap-2">
<div className="relative flex-1">
<SearchIcon className="pointer-events-none absolute top-1/2 left-2 size-3.5 -translate-y-1/2 text-muted-foreground" />
<Input
value={keywordInput}
onChange={(event) => setKeywordInput(event.target.value)}
onKeyDown={handleFilterKeyDown}
placeholder="搜索文档标题"
className="h-8 pl-8 text-xs"
/>
</div>
<Select
value={statusFilterInput}
onValueChange={handleStatusFilterChange}
>
<SelectTrigger className="h-8 w-32 text-xs">
<SelectValue>
{statusFilterInput === "all" ? "全部状态" : getEnumLabel(StatusLabels, Number(statusFilterInput))}
</SelectValue>
</SelectTrigger>
<SelectContent>
{statusOptions.map((item) => (
<SelectItem
key={item.value}
value={item.value}
className="text-xs"
>
{item.label}
</SelectItem>
))}
</SelectContent>
</Select>
<Select
value={indexStatusFilterInput}
onValueChange={handleIndexStatusFilterChange}
>
<SelectTrigger className="h-8 w-36 text-xs">
<SelectValue>
{indexStatusFilterInput === "all"
? "全部索引状态"
: getEnumLabel(
KnowledgeDocumentIndexStatusLabels,
indexStatusFilterInput as KnowledgeDocumentIndexStatus
)}
</SelectValue>
</SelectTrigger>
<SelectContent>
{indexStatusOptions.map((item) => (
<SelectItem
key={item.value}
value={item.value}
className="text-xs"
>
{item.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="min-h-0 flex-1">
<ScrollArea className="h-full">
<div className={viewMode === "grid" ? "p-2 space-y-1" : "p-2 space-y-0.5"}>
{documents.results.map((item) => (
viewMode === "grid" ? (
<ContextMenu key={item.id}>
<ContextMenuTrigger className="w-full">
<div
className="bg-background p-3 transition-colors hover:bg-accent w-full"
>
<div className="flex items-start justify-between gap-2">
<div className="flex min-w-0 flex-1 items-start gap-2">
{/* <FileTextIcon className="mt-0.5 size-4 shrink-0 text-muted-foreground" /> */}
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<div className="text-sm font-medium">{item.title}</div>
{renderIndexStatusBadge(item)}
</div>
<div className="mt-1 text-xs text-muted-foreground line-clamp-2">
{getDocumentPreview(item.content, item.contentType)}
</div>
<div className="mt-2 flex flex-wrap items-center gap-x-2 gap-y-1 text-xs text-muted-foreground">
<span>{item.createUserName || "-"}</span>
<span>{formatDateTime(item.createdAt)}</span>
<span className={cn(item.indexStatus === KnowledgeDocumentIndexStatus.Failed && "text-destructive")}>
{item.indexStatus === KnowledgeDocumentIndexStatus.Indexed
? `已索引 ${formatDateTime(item.indexedAt)}`
: item.indexStatusName}
</span>
</div>
</div>
</div>
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button
variant="ghost"
size="icon"
className="size-6"
/>
}
aria-label={`更多操作 ${item.title}`}
>
<MoreHorizontalIcon className="size-3.5" />
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-32 min-w-32">
<DropdownMenuItem onClick={() => openEditDialog(item)}>
<PencilIcon className="mr-2 size-3.5" />
</DropdownMenuItem>
<DropdownMenuItem onClick={() => void handleBuildIndex(item)}>
<WrenchIcon className="mr-2 size-3.5" />
{actionLoadingMap[item.id]?.rebuildIndex ? "执行中..." : "重建索引"}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => void handleDelete(item)}
className="text-destructive focus:text-destructive"
>
<Trash2Icon className="mr-2 size-3.5" />
{actionLoadingMap[item.id]?.delete ? "删除中..." : "删除"}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
</ContextMenuTrigger>
<ContextMenuContent className="w-40">
<ContextMenuItem onClick={() => openEditDialog(item)}>
<PencilIcon className="mr-2 size-3.5" />
</ContextMenuItem>
<ContextMenuItem onClick={() => void handleBuildIndex(item)} disabled={actionLoadingMap[item.id]?.rebuildIndex}>
<WrenchIcon className="mr-2 size-3.5" />
{actionLoadingMap[item.id]?.rebuildIndex ? "执行中..." : "重建索引"}
</ContextMenuItem>
<ContextMenuItem
onClick={() => void handleDelete(item)}
variant="destructive"
disabled={actionLoadingMap[item.id]?.delete}
>
<Trash2Icon className="mr-2 size-3.5" />
{actionLoadingMap[item.id]?.delete ? "删除中..." : "删除"}
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
) : (
<ContextMenu key={item.id}>
<ContextMenuTrigger className="w-full">
<div
className="flex items-center gap-3 bg-background p-2 transition-colors hover:bg-accent w-full"
>
{/* <FileTextIcon className="size-4 shrink-0 text-muted-foreground" /> */}
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<div className="truncate text-sm font-medium">{item.title}</div>
{renderIndexStatusBadge(item)}
</div>
<div className="mt-0.5 truncate text-xs text-muted-foreground">
{getDocumentPreview(item.content, item.contentType)}
</div>
<div className="mt-1 text-xs text-muted-foreground">
{item.indexStatus === KnowledgeDocumentIndexStatus.Indexed
? `索引时间:${formatDateTime(item.indexedAt)}`
: item.indexError || item.indexStatusName}
</div>
</div>
<div className="shrink-0 text-xs text-muted-foreground">
{formatDateTime(item.createdAt)}
</div>
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button
variant="ghost"
size="icon"
className="size-6"
/>
}
aria-label={`更多操作 ${item.title}`}
>
<MoreHorizontalIcon className="size-3.5" />
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-32 min-w-32">
<DropdownMenuItem onClick={() => openEditDialog(item)}>
<PencilIcon className="mr-2 size-3.5" />
</DropdownMenuItem>
<DropdownMenuItem onClick={() => void handleBuildIndex(item)}>
<WrenchIcon className="mr-2 size-3.5" />
{actionLoadingMap[item.id]?.rebuildIndex ? "执行中..." : "重建索引"}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => void handleDelete(item)}
className="text-destructive focus:text-destructive"
>
<Trash2Icon className="mr-2 size-3.5" />
{actionLoadingMap[item.id]?.delete ? "删除中..." : "删除"}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</ContextMenuTrigger>
<ContextMenuContent className="w-40">
<ContextMenuItem onClick={() => openEditDialog(item)}>
<PencilIcon className="mr-2 size-3.5" />
</ContextMenuItem>
<ContextMenuItem onClick={() => void handleBuildIndex(item)} disabled={actionLoadingMap[item.id]?.rebuildIndex}>
<WrenchIcon className="mr-2 size-3.5" />
{actionLoadingMap[item.id]?.rebuildIndex ? "执行中..." : "重建索引"}
</ContextMenuItem>
<ContextMenuItem
onClick={() => void handleDelete(item)}
variant="destructive"
disabled={actionLoadingMap[item.id]?.delete}
>
<Trash2Icon className="mr-2 size-3.5" />
{actionLoadingMap[item.id]?.delete ? "删除中..." : "删除"}
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
)
))}
{!loading && documents.results.length === 0 ? (
<div className="py-8 text-center text-sm text-muted-foreground">
</div>
) : null}
</div>
</ScrollArea>
</div>
</div>
<DocumentEditDialog
open={dialogOpen}
saving={saving}
itemId={editingItem?.id ?? null}
knowledgeBaseId={knowledgeBaseId}
onOpenChange={handleDialogOpenChange}
onSubmit={handleSubmit}
/>
</>
);
}
@@ -0,0 +1,218 @@
"use client";
import { useEffect, useState } from "react";
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm, type Resolver } from "react-hook-form";
import { z } from "zod/v4";
import { ProjectDialog } from "@/components/project-dialog";
import { Button } from "@/components/ui/button";
import { Field, FieldContent, FieldError, FieldLabel } from "@/components/ui/field";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import {
fetchKnowledgeFAQ,
type CreateKnowledgeFAQPayload,
type KnowledgeFAQ,
} from "@/lib/api/admin";
type FAQEditDialogProps = {
open: boolean;
saving: boolean;
itemId: number | null;
knowledgeBaseId: number | null;
onOpenChange: (open: boolean) => void;
onSubmit: (payload: CreateKnowledgeFAQPayload) => Promise<void>;
};
const formSchema = z.object({
question: z.string().trim().min(1, "问题不能为空").max(500, "问题最多500个字符"),
answer: z.string().trim().min(1, "答案不能为空"),
similarQuestionsText: z.string(),
remark: z.string().trim().max(500, "备注最多500个字符"),
});
type EditForm = z.infer<typeof formSchema>;
const resolver = zodResolver(formSchema as never) as Resolver<
z.input<typeof formSchema>,
undefined,
z.output<typeof formSchema>
>;
const emptyForm: EditForm = {
question: "",
answer: "",
similarQuestionsText: "",
remark: "",
};
function buildForm(item: KnowledgeFAQ | null): EditForm {
if (!item) {
return emptyForm;
}
return {
question: item.question,
answer: item.answer,
similarQuestionsText: (item.similarQuestions ?? []).join("\n"),
remark: item.remark ?? "",
};
}
function buildPayload(form: EditForm, knowledgeBaseId: number): CreateKnowledgeFAQPayload {
return {
knowledgeBaseId,
question: form.question.trim(),
answer: form.answer.trim(),
similarQuestions: form.similarQuestionsText
.split("\n")
.map((item) => item.trim())
.filter(Boolean),
remark: form.remark.trim(),
};
}
export function FAQEditDialog({
open,
saving,
itemId,
knowledgeBaseId,
onOpenChange,
onSubmit,
}: FAQEditDialogProps) {
if (!open || !knowledgeBaseId) {
return null;
}
return (
<FAQEditDialogBody
key={itemId ? `edit-${itemId}` : "create"}
open={open}
saving={saving}
itemId={itemId}
knowledgeBaseId={knowledgeBaseId}
onOpenChange={onOpenChange}
onSubmit={onSubmit}
/>
);
}
type FAQEditDialogBodyProps = {
open: boolean;
saving: boolean;
itemId: number | null;
knowledgeBaseId: number;
onOpenChange: (open: boolean) => void;
onSubmit: (payload: CreateKnowledgeFAQPayload) => Promise<void>;
};
function FAQEditDialogBody({
open,
saving,
itemId,
knowledgeBaseId,
onOpenChange,
onSubmit,
}: FAQEditDialogBodyProps) {
const [loading, setLoading] = useState(false);
const formId = "knowledge-faq-edit-form";
const form = useForm<
z.input<typeof formSchema>,
undefined,
z.output<typeof formSchema>
>({
resolver,
defaultValues: emptyForm,
});
const {
handleSubmit,
reset,
register,
formState: { errors },
} = form;
useEffect(() => {
async function loadDetail() {
if (!itemId) {
reset(emptyForm);
return;
}
setLoading(true);
try {
const data = await fetchKnowledgeFAQ(itemId);
reset(buildForm(data));
} finally {
setLoading(false);
}
}
if (open) {
void loadDetail();
}
}, [itemId, open, reset]);
async function onFormSubmit(values: EditForm) {
await onSubmit(buildPayload(values, knowledgeBaseId));
}
return (
<ProjectDialog
open={open}
onOpenChange={onOpenChange}
title={itemId ? "编辑FAQ" : "新建FAQ"}
allowFullscreen
size="xl"
footer={
<>
<Button type="button" variant="outline" onClick={() => onOpenChange(false)} disabled={saving}>
</Button>
<Button type="submit" form={formId} disabled={saving || loading}>
{saving ? "保存中..." : itemId ? "保存" : "创建"}
</Button>
</>
}
>
{loading ? (
<div className="flex items-center justify-center py-12 text-muted-foreground">...</div>
) : (
<form id={formId} onSubmit={handleSubmit(onFormSubmit)} className="space-y-4">
<Field data-invalid={!!errors.question}>
<FieldLabel htmlFor="faq-question"></FieldLabel>
<FieldContent>
<Input id="faq-question" placeholder="请输入标准问题" {...register("question")} />
<FieldError errors={[errors.question]} />
</FieldContent>
</Field>
<Field data-invalid={!!errors.answer}>
<FieldLabel htmlFor="faq-answer"></FieldLabel>
<FieldContent>
<Textarea id="faq-answer" rows={8} placeholder="请输入FAQ答案" {...register("answer")} />
<FieldError errors={[errors.answer]} />
</FieldContent>
</Field>
<Field>
<FieldLabel htmlFor="faq-similar-questions"></FieldLabel>
<FieldContent>
<Textarea
id="faq-similar-questions"
rows={5}
placeholder={"一行一个相似问题"}
{...register("similarQuestionsText")}
/>
</FieldContent>
</Field>
<Field data-invalid={!!errors.remark}>
<FieldLabel htmlFor="faq-remark"></FieldLabel>
<FieldContent>
<Textarea id="faq-remark" rows={3} placeholder="备注" {...register("remark")} />
<FieldError errors={[errors.remark]} />
</FieldContent>
</Field>
</form>
)}
</ProjectDialog>
);
}
@@ -0,0 +1,402 @@
"use client";
import { DownloadIcon, FileUpIcon, InfoIcon } from "lucide-react";
import { useMemo, useRef, useState } from "react";
import { toast } from "sonner";
import { ProjectDialog } from "@/components/project-dialog";
import { Button } from "@/components/ui/button";
import {
Field,
FieldContent,
FieldDescription,
FieldGroup,
FieldLabel,
} from "@/components/ui/field";
import { Input } from "@/components/ui/input";
import { ScrollArea } from "@/components/ui/scroll-area";
import { createKnowledgeFAQ, type CreateKnowledgeFAQPayload } from "@/lib/api/admin";
type FAQImportDialogProps = {
open: boolean;
knowledgeBaseId: number | null;
importing: boolean;
onOpenChange: (open: boolean) => void;
onImportingChange: (importing: boolean) => void;
onImported: () => Promise<void>;
};
type ParsedFAQRow = {
rowNo: number;
question: string;
answer: string;
similarQuestions: string[];
remark: string;
};
type ParseResult = {
rows: ParsedFAQRow[];
warnings: string[];
};
const templateContent = [
"question,answer,similarQuestions,remark",
'"如何重置密码?","进入个人设置后点击重置密码。","忘记密码|密码重置在哪里","账号类FAQ"',
'"支持哪些接入渠道?","目前支持网站组件、企业微信等渠道接入。","有哪些渠道|支持什么渠道","渠道说明"',
].join("\n");
const acceptedHeaderMap: Record<string, keyof Omit<ParsedFAQRow, "rowNo">> = {
question: "question",
"标准问题": "question",
"问题": "question",
answer: "answer",
"答案": "answer",
similarquestions: "similarQuestions",
"similarQuestions": "similarQuestions",
"相似问": "similarQuestions",
"相似问题": "similarQuestions",
remark: "remark",
"备注": "remark",
};
function normalizeHeader(value: string) {
return value.trim().replace(/^\uFEFF/, "").toLowerCase();
}
function parseDelimitedText(input: string): string[][] {
const text = input.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
const rows: string[][] = [];
let row: string[] = [];
let cell = "";
let inQuotes = false;
let delimiter = ",";
function pushCell() {
row.push(cell.trim());
cell = "";
}
function pushRow() {
if (row.length === 1 && row[0] === "" && rows.length === 0) {
row = [];
return;
}
if (row.some((item) => item !== "")) {
rows.push(row);
}
row = [];
}
for (let i = 0; i < text.length; i += 1) {
const char = text[i];
const next = text[i + 1];
if (!inQuotes && rows.length === 0 && row.length === 0 && cell.length > 0 && char === "\t") {
delimiter = "\t";
}
if (char === '"') {
if (inQuotes && next === '"') {
cell += '"';
i += 1;
continue;
}
inQuotes = !inQuotes;
continue;
}
if (!inQuotes && char === delimiter) {
pushCell();
continue;
}
if (!inQuotes && char === "\n") {
pushCell();
pushRow();
continue;
}
cell += char;
}
if (cell.length > 0 || row.length > 0) {
pushCell();
pushRow();
}
return rows;
}
function parseSimilarQuestions(value: string) {
return value
.split(/\r?\n|\|/g)
.map((item) => item.trim())
.filter(Boolean);
}
function parseFAQFileContent(input: string): ParseResult {
const table = parseDelimitedText(input);
if (table.length === 0) {
throw new Error("文件内容为空");
}
const headerRow = table[0];
const headerMap = new Map<keyof Omit<ParsedFAQRow, "rowNo">, number>();
for (let index = 0; index < headerRow.length; index += 1) {
const header = acceptedHeaderMap[normalizeHeader(headerRow[index])];
if (header && !headerMap.has(header)) {
headerMap.set(header, index);
}
}
if (!headerMap.has("question") || !headerMap.has("answer")) {
throw new Error("导入模板缺少 question/answer 列");
}
const rows: ParsedFAQRow[] = [];
const warnings: string[] = [];
for (let index = 1; index < table.length; index += 1) {
const current = table[index];
const rowNo = index + 1;
const question = current[headerMap.get("question") ?? -1]?.trim() ?? "";
const answer = current[headerMap.get("answer") ?? -1]?.trim() ?? "";
const similarQuestionsRaw = current[headerMap.get("similarQuestions") ?? -1]?.trim() ?? "";
const remark = current[headerMap.get("remark") ?? -1]?.trim() ?? "";
if (!question && !answer && !similarQuestionsRaw && !remark) {
continue;
}
if (!question || !answer) {
warnings.push(`${rowNo} 行缺少问题或答案,已跳过`);
continue;
}
rows.push({
rowNo,
question,
answer,
similarQuestions: parseSimilarQuestions(similarQuestionsRaw),
remark,
});
}
return { rows, warnings };
}
function downloadTemplate() {
const blob = new Blob([templateContent], { type: "text/csv;charset=utf-8;" });
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = "knowledge-faq-import-template.csv";
link.click();
URL.revokeObjectURL(url);
}
function buildPayload(row: ParsedFAQRow, knowledgeBaseId: number): CreateKnowledgeFAQPayload {
return {
knowledgeBaseId,
question: row.question,
answer: row.answer,
similarQuestions: row.similarQuestions,
remark: row.remark,
};
}
export function FAQImportDialog({
open,
knowledgeBaseId,
importing,
onOpenChange,
onImportingChange,
onImported,
}: FAQImportDialogProps) {
const fileInputRef = useRef<HTMLInputElement | null>(null);
const [fileName, setFileName] = useState("");
const [rows, setRows] = useState<ParsedFAQRow[]>([]);
const [warnings, setWarnings] = useState<string[]>([]);
const previewRows = useMemo(() => rows.slice(0, 5), [rows]);
function resetState() {
setFileName("");
setRows([]);
setWarnings([]);
if (fileInputRef.current) {
fileInputRef.current.value = "";
}
}
async function handleFileChange(event: React.ChangeEvent<HTMLInputElement>) {
const file = event.target.files?.[0];
if (!file) {
return;
}
try {
const content = await file.text();
const parsed = parseFAQFileContent(content);
setFileName(file.name);
setRows(parsed.rows);
setWarnings(parsed.warnings);
if (parsed.rows.length === 0) {
toast.error("没有可导入的FAQ记录");
} else {
toast.success(`已解析 ${parsed.rows.length} 条FAQ`);
}
} catch (error) {
resetState();
toast.error(error instanceof Error ? error.message : "解析导入文件失败");
}
}
async function handleImport() {
if (!knowledgeBaseId || rows.length === 0 || importing) {
return;
}
onImportingChange(true);
let successCount = 0;
const failedRows: string[] = [];
try {
for (const row of rows) {
try {
await createKnowledgeFAQ(buildPayload(row, knowledgeBaseId));
successCount += 1;
} catch (error) {
failedRows.push(
`${row.rowNo} 行:${error instanceof Error ? error.message : "导入失败"}`
);
}
}
await onImported();
if (successCount > 0) {
toast.success(`成功导入 ${successCount} 条FAQ`);
}
if (failedRows.length > 0) {
toast.error(`${failedRows.length} 条FAQ导入失败`);
setWarnings((current) => [...current, ...failedRows]);
return;
}
resetState();
onOpenChange(false);
} finally {
onImportingChange(false);
}
}
return (
<ProjectDialog
open={open}
onOpenChange={(nextOpen) => {
if (!nextOpen && !importing) {
resetState();
}
onOpenChange(nextOpen);
}}
title="导入FAQ"
description="上传 CSV 文件批量导入 FAQ。必填列为 question、answersimilarQuestions 使用 | 或换行分隔。"
size="lg"
footer={
<>
<Button type="button" variant="outline" onClick={() => downloadTemplate()}>
<DownloadIcon className="size-4" />
</Button>
<Button type="button" variant="outline" onClick={() => onOpenChange(false)} disabled={importing}>
</Button>
<Button type="button" onClick={() => void handleImport()} disabled={importing || rows.length === 0}>
{importing ? "导入中..." : `开始导入${rows.length > 0 ? ` (${rows.length})` : ""}`}
</Button>
</>
}
>
<FieldGroup>
<Field>
<FieldLabel htmlFor="faq-import-file"></FieldLabel>
<FieldContent>
<div className="flex items-center gap-2">
<Input
id="faq-import-file"
ref={fileInputRef}
type="file"
accept=".csv,text/csv,.txt"
onChange={(event) => void handleFileChange(event)}
/>
<Button
type="button"
variant="outline"
onClick={() => fileInputRef.current?.click()}
>
<FileUpIcon className="size-4" />
</Button>
</div>
<FieldDescription>
UTF-8 CSV
</FieldDescription>
</FieldContent>
</Field>
{fileName ? (
<div className="rounded-md border bg-muted/20 px-3 py-2 text-sm">
{fileName}
</div>
) : null}
{warnings.length > 0 ? (
<div className="rounded-md border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-900">
<div className="mb-2 flex items-center gap-2 font-medium">
<InfoIcon className="size-4" />
</div>
<ul className="space-y-1">
{warnings.map((item, index) => (
<li key={`${item}-${index}`}>{item}</li>
))}
</ul>
</div>
) : null}
<div className="rounded-md border">
<div className="border-b px-4 py-3 text-sm font-medium">
</div>
{previewRows.length > 0 ? (
<ScrollArea className="max-h-80">
<div className="divide-y">
{previewRows.map((row) => (
<div key={row.rowNo} className="space-y-2 px-4 py-3 text-sm">
<div>
<span className="text-muted-foreground"> {row.rowNo} </span>
</div>
<div>
<div className="font-medium">{row.question}</div>
<div className="mt-1 whitespace-pre-wrap text-muted-foreground">
{row.answer}
</div>
</div>
<div className="text-muted-foreground">
{row.similarQuestions.length > 0 ? row.similarQuestions.join(" / ") : "无"}
</div>
<div className="text-muted-foreground">{row.remark || "无"}</div>
</div>
))}
</div>
</ScrollArea>
) : (
<div className="px-4 py-12 text-center text-sm text-muted-foreground">
5 FAQ
</div>
)}
</div>
</FieldGroup>
</ProjectDialog>
);
}
@@ -0,0 +1,453 @@
"use client";
import {
MoreHorizontalIcon,
SearchIcon,
Trash2Icon,
WrenchIcon,
} from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import { toast } from "sonner";
import { ListPagination } from "@/components/list-pagination";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { ButtonGroup } from "@/components/ui/button-group";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import {
buildKnowledgeFAQIndex,
createKnowledgeFAQ,
deleteKnowledgeFAQ,
fetchKnowledgeFAQs,
updateKnowledgeFAQ,
type CreateKnowledgeFAQPayload,
type KnowledgeFAQ,
type PageResult,
} from "@/lib/api/admin";
import { getEnumLabel, getEnumOptions } from "@/lib/enums";
import {
KnowledgeDocumentIndexStatus,
KnowledgeDocumentIndexStatusLabels,
} from "@/lib/generated/enums";
import { formatDateTime } from "@/lib/utils";
import { FAQEditDialog } from "./faq-edit";
import { FAQImportDialog } from "./faq-import-dialog";
type FAQListProps = {
knowledgeBaseId: number | null;
onActionStateChange?: (state: FAQListActionState) => void;
};
export type FAQListActionState = {
onRefresh: () => void;
onCreate: () => void;
onImport: () => void;
loading: boolean;
importing: boolean;
};
const indexStatusOptions = [
{ value: "all", label: "全部索引状态" },
...getEnumOptions(KnowledgeDocumentIndexStatusLabels),
] as const;
function getIndexStatusBadgeVariant(status: string) {
switch (status) {
case KnowledgeDocumentIndexStatus.Indexed:
return "secondary" as const;
case KnowledgeDocumentIndexStatus.Failed:
return "destructive" as const;
default:
return "outline" as const;
}
}
function renderIndexStatusBadge(item: KnowledgeFAQ) {
const badge = (
<Badge variant={getIndexStatusBadgeVariant(item.indexStatus)}>
{item.indexStatusName}
</Badge>
);
if (
item.indexStatus !== KnowledgeDocumentIndexStatus.Failed ||
!item.indexError
) {
return badge;
}
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger>
<span className="inline-flex">{badge}</span>
</TooltipTrigger>
<TooltipContent align="start" className="max-w-sm whitespace-normal">
{item.indexError}
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
}
export function FAQList({
knowledgeBaseId,
onActionStateChange,
}: FAQListProps) {
const [keywordInput, setKeywordInput] = useState("");
const [indexStatusFilterInput, setIndexStatusFilterInput] = useState("all");
const [keyword, setKeyword] = useState("");
const [indexStatusFilter, setIndexStatusFilter] = useState("all");
const [page, setPage] = useState(1);
const [limit, setLimit] = useState(20);
const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false);
const [importing, setImporting] = useState(false);
const [actionLoadingMap, setActionLoadingMap] = useState<
Record<number, { rebuildIndex: boolean; delete: boolean }>
>({});
const [dialogOpen, setDialogOpen] = useState(false);
const [importDialogOpen, setImportDialogOpen] = useState(false);
const [editingItem, setEditingItem] = useState<KnowledgeFAQ | null>(null);
const [result, setResult] = useState<PageResult<KnowledgeFAQ>>({
results: [],
page: { page: 1, limit: 20, total: 0 },
});
const loadData = useCallback(
async (options?: {
keyword?: string;
indexStatusFilter?: string;
page?: number;
limit?: number;
}) => {
const nextKeyword = options?.keyword ?? keyword;
const nextIndexStatusFilter =
options?.indexStatusFilter ?? indexStatusFilter;
const nextPage = options?.page ?? page;
const nextLimit = options?.limit ?? limit;
if (!knowledgeBaseId) {
setResult({
results: [],
page: { page: 1, limit: 20, total: 0 },
});
return;
}
setLoading(true);
try {
const data = await fetchKnowledgeFAQs({
knowledgeBaseId,
question: nextKeyword.trim() || undefined,
indexStatus:
nextIndexStatusFilter === "all" ? undefined : nextIndexStatusFilter,
page: nextPage,
limit: nextLimit,
});
setResult(data);
} catch (error) {
toast.error(error instanceof Error ? error.message : "加载FAQ失败");
} finally {
setLoading(false);
}
},
[indexStatusFilter, keyword, knowledgeBaseId, limit, page],
);
useEffect(() => {
void loadData();
}, [knowledgeBaseId, loadData]);
useEffect(() => {
onActionStateChange?.({
onRefresh: () => void loadData(),
onCreate: () => {
setEditingItem(null);
setDialogOpen(true);
},
onImport: () => setImportDialogOpen(true),
loading,
importing,
});
}, [importing, loadData, loading, onActionStateChange]);
function applyFilters() {
const nextKeyword = keywordInput;
const nextIndexStatusFilter = indexStatusFilterInput;
setKeyword(nextKeyword);
setIndexStatusFilter(nextIndexStatusFilter);
setPage(1);
void loadData({
keyword: nextKeyword,
indexStatusFilter: nextIndexStatusFilter,
page: 1,
});
}
async function handleSubmit(payload: CreateKnowledgeFAQPayload) {
setSaving(true);
try {
if (editingItem) {
await updateKnowledgeFAQ({ id: editingItem.id, ...payload });
} else {
await createKnowledgeFAQ(payload);
}
setDialogOpen(false);
setEditingItem(null);
await loadData();
toast.success("FAQ已保存");
} catch (error) {
toast.error(error instanceof Error ? error.message : "保存FAQ失败");
} finally {
setSaving(false);
}
}
async function handleDelete(item: KnowledgeFAQ) {
setActionLoadingMap((prev) => ({
...prev,
[item.id]: { ...prev[item.id], delete: true },
}));
try {
await deleteKnowledgeFAQ(item.id);
toast.success("FAQ已删除");
await loadData();
} catch (error) {
toast.error(error instanceof Error ? error.message : "删除FAQ失败");
} finally {
setActionLoadingMap((prev) => ({
...prev,
[item.id]: { ...prev[item.id], delete: false },
}));
}
}
async function handleBuildIndex(item: KnowledgeFAQ) {
setActionLoadingMap((prev) => ({
...prev,
[item.id]: { ...prev[item.id], rebuildIndex: true },
}));
try {
await buildKnowledgeFAQIndex(item.id);
toast.success("FAQ索引已重建");
await loadData();
} catch (error) {
toast.error(error instanceof Error ? error.message : "重建FAQ索引失败");
} finally {
setActionLoadingMap((prev) => ({
...prev,
[item.id]: { ...prev[item.id], rebuildIndex: false },
}));
}
}
if (!knowledgeBaseId) {
return (
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
FAQ知识库查看FAQ
</div>
);
}
return (
<>
<div className="flex h-full flex-col gap-4 p-4">
<div className="flex items-center gap-2">
<div className="relative max-w-md flex-1">
<SearchIcon className="pointer-events-none absolute top-1/2 left-3 size-4 -translate-y-1/2 text-muted-foreground" />
<Input
value={keywordInput}
onChange={(event) => setKeywordInput(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter") {
applyFilters();
}
}}
placeholder="按问题搜索FAQ"
className="pl-9"
/>
</div>
<Select
value={indexStatusFilterInput}
onValueChange={(value) => setIndexStatusFilterInput(value ?? "all")}
>
<SelectTrigger className="w-40">
<SelectValue>
{indexStatusFilterInput === "all"
? "全部索引状态"
: getEnumLabel(
KnowledgeDocumentIndexStatusLabels,
indexStatusFilterInput as KnowledgeDocumentIndexStatus,
)}
</SelectValue>
</SelectTrigger>
<SelectContent>
{indexStatusOptions.map((item) => (
<SelectItem key={item.value} value={item.value}>
{item.label}
</SelectItem>
))}
</SelectContent>
</Select>
<Button
variant="outline"
onClick={applyFilters}
disabled={loading}
>
</Button>
</div>
<div className="min-h-0 flex-1 overflow-hidden rounded-md border">
<div className="h-full overflow-auto">
<table className="w-full min-w-max caption-bottom text-sm">
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="w-20 text-right"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{result.results.map((item) => (
<TableRow key={item.id}>
<TableCell className="max-w-sm">
<div className="font-medium">{item.question}</div>
<div className="mt-1 line-clamp-2 text-sm text-muted-foreground">
{item.answer}
</div>
</TableCell>
<TableCell>{renderIndexStatusBadge(item)}</TableCell>
<TableCell>
{Array.isArray(item.similarQuestions)
? item.similarQuestions.length
: 0}
</TableCell>
<TableCell>{formatDateTime(item.updatedAt)}</TableCell>
<TableCell className="w-20 text-right">
<ButtonGroup className="ml-auto">
<Button
variant="outline"
size="sm"
onClick={() => {
setEditingItem(item);
setDialogOpen(true);
}}
>
</Button>
<DropdownMenu>
<DropdownMenuTrigger
render={<Button variant="outline" size="icon-sm" />}
aria-label={`更多操作 ${item.question}`}
>
<MoreHorizontalIcon className="size-4" />
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={() => void handleBuildIndex(item)}
>
<WrenchIcon className="mr-2 size-4" />
{actionLoadingMap[item.id]?.rebuildIndex
? "重建中..."
: "重建索引"}
</DropdownMenuItem>
<DropdownMenuItem
className="text-destructive focus:text-destructive"
onClick={() => void handleDelete(item)}
>
<Trash2Icon className="mr-2 size-4" />
{actionLoadingMap[item.id]?.delete
? "删除中..."
: "删除"}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</ButtonGroup>
</TableCell>
</TableRow>
))}
{!loading && result.results.length === 0 ? (
<TableRow>
<TableCell
colSpan={5}
className="py-12 text-center text-sm text-muted-foreground"
>
FAQ
</TableCell>
</TableRow>
) : null}
</TableBody>
</table>
</div>
</div>
<ListPagination
page={result.page.page}
limit={result.page.limit}
total={result.page.total}
onPageChange={(nextPage: number) => {
setPage(nextPage);
void loadData({ page: nextPage });
}}
onLimitChange={(next: number) => {
setLimit(next);
setPage(1);
void loadData({ limit: next, page: 1 });
}}
/>
</div>
<FAQEditDialog
open={dialogOpen}
saving={saving}
itemId={editingItem?.id ?? null}
knowledgeBaseId={knowledgeBaseId}
onOpenChange={(open) => {
if (!open) {
setEditingItem(null);
}
setDialogOpen(open);
}}
onSubmit={handleSubmit}
/>
<FAQImportDialog
open={importDialogOpen}
knowledgeBaseId={knowledgeBaseId}
importing={importing}
onOpenChange={setImportDialogOpen}
onImportingChange={setImporting}
onImported={loadData}
/>
</>
);
}
@@ -0,0 +1,490 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { useEffect, useState } from "react";
import { Controller, Resolver, useForm } from "react-hook-form";
import { z } from "zod/v4";
import { OptionCombobox } from "@/components/option-combobox";
import { ProjectDialog } from "@/components/project-dialog";
import { Button } from "@/components/ui/button";
import {
Field,
FieldContent,
FieldError,
FieldLabel,
} from "@/components/ui/field";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import {
fetchKnowledgeBase,
type CreateKnowledgeBasePayload,
type KnowledgeBase,
} from "@/lib/api/admin";
import { getEnumLabel, getEnumOptions } from "@/lib/enums";
import {
KnowledgeAnswerMode,
KnowledgeAnswerModeLabels,
KnowledgeBaseType,
KnowledgeBaseTypeLabels,
KnowledgeChunkProvider,
KnowledgeChunkProviderLabels,
} from "@/lib/generated/enums";
type KnowledgeBaseEditDialogProps = {
open: boolean;
saving: boolean;
itemId: number | null;
onOpenChange: (open: boolean) => void;
onSubmit: (payload: CreateKnowledgeBasePayload) => Promise<void>;
};
const emptyForm: EditForm = {
name: "",
description: "",
knowledgeType: KnowledgeBaseType.Document,
defaultTopK: "5",
defaultScoreThreshold: "0.2",
defaultRerankLimit: "10",
chunkProvider: KnowledgeChunkProvider.Structured,
chunkTargetTokens: "300",
chunkMaxTokens: "400",
chunkOverlapTokens: "40",
answerMode: String(KnowledgeAnswerMode.Strict),
remark: "",
};
const knowledgeBaseFormSchema = z.object({
name: z.string().trim().min(1, "名称不能为空").max(100, "名称最多100个字符"),
description: z.string().trim().max(500, "描述最多500个字符"),
knowledgeType: z.string().trim().min(1, "请选择知识库类型"),
defaultTopK: z.string().trim().min(1, "请输入TopK值"),
defaultScoreThreshold: z.string().trim().min(1, "请输入分数阈值"),
defaultRerankLimit: z.string().trim().min(1, "请输入重排序限制"),
chunkProvider: z.string().trim().min(1, "请选择分块策略"),
chunkTargetTokens: z.string().trim().min(1, "请输入目标 token 数"),
chunkMaxTokens: z.string().trim().min(1, "请输入最大 token 数"),
chunkOverlapTokens: z.string().trim().min(1, "请输入重叠 token 数"),
answerMode: z.string().trim().min(1, "请选择回答模式"),
remark: z.string().trim().max(500, "备注最多500个字符"),
});
type EditForm = z.infer<typeof knowledgeBaseFormSchema>;
const editFormResolver = zodResolver(
knowledgeBaseFormSchema as never,
) as Resolver<
z.input<typeof knowledgeBaseFormSchema>,
undefined,
z.output<typeof knowledgeBaseFormSchema>
>;
function buildForm(item: KnowledgeBase | null): EditForm {
if (!item) {
return emptyForm;
}
return {
name: item.name,
description: item.description || "",
knowledgeType: item.knowledgeType || KnowledgeBaseType.Document,
defaultTopK: String(item.defaultTopK),
defaultScoreThreshold: String(item.defaultScoreThreshold),
defaultRerankLimit: String(item.defaultRerankLimit),
chunkProvider: item.chunkProvider,
chunkTargetTokens: String(item.chunkTargetTokens),
chunkMaxTokens: String(item.chunkMaxTokens),
chunkOverlapTokens: String(item.chunkOverlapTokens),
answerMode: String(item.answerMode),
remark: item.remark || "",
};
}
function buildPayload(form: EditForm): CreateKnowledgeBasePayload {
return {
name: form.name.trim(),
description: form.description.trim(),
knowledgeType: form.knowledgeType,
defaultTopK: Number(form.defaultTopK),
defaultScoreThreshold: Number(form.defaultScoreThreshold),
defaultRerankLimit: Number(form.defaultRerankLimit),
chunkProvider: form.chunkProvider,
chunkTargetTokens: Number(form.chunkTargetTokens),
chunkMaxTokens: Number(form.chunkMaxTokens),
chunkOverlapTokens: Number(form.chunkOverlapTokens),
answerMode: Number(form.answerMode),
remark: form.remark.trim(),
};
}
export function EditDialog({
open,
saving,
itemId,
onOpenChange,
onSubmit,
}: KnowledgeBaseEditDialogProps) {
if (!open) {
return null;
}
return (
<KnowledgeBaseFormDialogBody
key={itemId ? `edit-${itemId}` : "create"}
open={open}
itemId={itemId}
saving={saving}
onOpenChange={onOpenChange}
onSubmit={onSubmit}
/>
);
}
type KnowledgeBaseFormDialogBodyProps = {
open: boolean;
saving: boolean;
itemId: number | null;
onOpenChange: (open: boolean) => void;
onSubmit: (payload: CreateKnowledgeBasePayload) => Promise<void>;
};
function KnowledgeBaseFormDialogBody({
open,
saving,
itemId,
onOpenChange,
onSubmit,
}: KnowledgeBaseFormDialogBodyProps) {
const formId = "knowledge-base-edit-form";
const [loading, setLoading] = useState(false);
const form = useForm<
z.input<typeof knowledgeBaseFormSchema>,
undefined,
z.output<typeof knowledgeBaseFormSchema>
>({
resolver: editFormResolver,
defaultValues: buildForm(null),
});
const {
control,
handleSubmit,
reset,
register,
watch,
formState: { errors },
} = form;
const knowledgeType = watch("knowledgeType");
const isFAQKnowledgeBase = knowledgeType === KnowledgeBaseType.FAQ;
useEffect(() => {
if (!open) {
return;
}
if (itemId === null) {
reset(buildForm(null));
return;
}
let cancelled = false;
async function loadItem() {
try {
setLoading(true);
const data = await fetchKnowledgeBase(itemId!);
if (!cancelled) {
reset(buildForm(data));
}
} catch (error) {
if (!cancelled) {
console.error("Failed to load knowledge base:", error);
}
} finally {
if (!cancelled) {
setLoading(false);
}
}
}
void loadItem();
return () => {
cancelled = true;
};
}, [open, itemId, reset]);
async function onFormSubmit(values: EditForm) {
const payload = buildPayload(values);
await onSubmit(payload);
}
return (
<ProjectDialog
open={open}
onOpenChange={onOpenChange}
title={itemId ? "编辑知识库" : "新建知识库"}
size="lg"
footer={
<>
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
disabled={saving || loading}
>
</Button>
<Button type="submit" form={formId} disabled={saving || loading}>
{saving ? "保存中..." : itemId ? "保存" : "创建"}
</Button>
</>
}
>
{loading ? (
<div className="flex items-center justify-center py-8">
<div className="text-sm text-muted-foreground">...</div>
</div>
) : (
<form
id={formId}
onSubmit={handleSubmit(onFormSubmit)}
className="space-y-4"
>
<Field data-invalid={!!errors.knowledgeType}>
<FieldLabel htmlFor="kb-knowledge-type"></FieldLabel>
<FieldContent>
<Controller
control={control}
name="knowledgeType"
render={({ field }) => (
<OptionCombobox
value={field.value}
options={getEnumOptions(KnowledgeBaseTypeLabels).map((option) => ({
value: String(option.value),
label: option.label,
}))}
placeholder="选择知识库类型"
searchPlaceholder="搜索知识库类型"
emptyText="没有匹配的知识库类型"
onChange={field.onChange}
/>
)}
/>
<FieldError errors={[errors.knowledgeType]} />
</FieldContent>
</Field>
<Field data-invalid={!!errors.name}>
<FieldLabel htmlFor="kb-name"></FieldLabel>
<FieldContent>
<Input
id="kb-name"
placeholder="知识库名称"
aria-invalid={!!errors.name}
{...register("name")}
/>
<FieldError errors={[errors.name]} />
</FieldContent>
</Field>
<Field data-invalid={!!errors.description}>
<FieldLabel htmlFor="kb-description"></FieldLabel>
<FieldContent>
<Textarea
id="kb-description"
placeholder="知识库描述"
rows={3}
aria-invalid={!!errors.description}
{...register("description")}
/>
<FieldError errors={[errors.description]} />
</FieldContent>
</Field>
{!isFAQKnowledgeBase ? (
<div className="grid grid-cols-1 gap-4 sm:grid-cols-4">
<Field data-invalid={!!errors.chunkProvider}>
<FieldLabel htmlFor="kb-chunk-provider"></FieldLabel>
<FieldContent>
<Controller
control={control}
name="chunkProvider"
render={({ field }) => (
<OptionCombobox
value={field.value}
options={getEnumOptions(KnowledgeChunkProviderLabels)
.filter((option) => option.value !== KnowledgeChunkProvider.FAQ)
.map((option) => ({
value: String(option.value),
label: option.label,
}))}
placeholder="选择分块策略"
searchPlaceholder="搜索分块策略"
emptyText="没有匹配的分块策略"
onChange={field.onChange}
/>
)}
/>
<FieldError errors={[errors.chunkProvider]} />
</FieldContent>
</Field>
<Field data-invalid={!!errors.chunkTargetTokens}>
<FieldLabel htmlFor="kb-chunk-target-tokens">
Token
</FieldLabel>
<FieldContent>
<Input
id="kb-chunk-target-tokens"
type="number"
min="1"
max="2000"
aria-invalid={!!errors.chunkTargetTokens}
{...register("chunkTargetTokens")}
/>
<FieldError errors={[errors.chunkTargetTokens]} />
</FieldContent>
</Field>
<Field data-invalid={!!errors.chunkMaxTokens}>
<FieldLabel htmlFor="kb-chunk-max-tokens"> Token</FieldLabel>
<FieldContent>
<Input
id="kb-chunk-max-tokens"
type="number"
min="1"
max="4000"
aria-invalid={!!errors.chunkMaxTokens}
{...register("chunkMaxTokens")}
/>
<FieldError errors={[errors.chunkMaxTokens]} />
</FieldContent>
</Field>
<Field data-invalid={!!errors.chunkOverlapTokens}>
<FieldLabel htmlFor="kb-chunk-overlap-tokens">
Token
</FieldLabel>
<FieldContent>
<Input
id="kb-chunk-overlap-tokens"
type="number"
min="0"
max="500"
aria-invalid={!!errors.chunkOverlapTokens}
{...register("chunkOverlapTokens")}
/>
<FieldError errors={[errors.chunkOverlapTokens]} />
</FieldContent>
</Field>
</div>
) : null}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-4">
<Field data-invalid={!!errors.defaultTopK}>
<FieldLabel htmlFor="kb-default-top-k">TopK</FieldLabel>
<FieldContent>
<Input
id="kb-default-top-k"
type="number"
min="1"
max="100"
aria-invalid={!!errors.defaultTopK}
{...register("defaultTopK")}
/>
<FieldError errors={[errors.defaultTopK]} />
</FieldContent>
</Field>
<Field data-invalid={!!errors.defaultScoreThreshold}>
<FieldLabel htmlFor="kb-default-score-threshold">
</FieldLabel>
<FieldContent>
<Input
id="kb-default-score-threshold"
type="number"
min="0"
max="1"
step="0.1"
aria-invalid={!!errors.defaultScoreThreshold}
{...register("defaultScoreThreshold")}
/>
<FieldError errors={[errors.defaultScoreThreshold]} />
</FieldContent>
</Field>
<Field data-invalid={!!errors.defaultRerankLimit}>
<FieldLabel htmlFor="kb-default-rerank-limit">
</FieldLabel>
<FieldContent>
<Input
id="kb-default-rerank-limit"
type="number"
min="0"
max="100"
aria-invalid={!!errors.defaultRerankLimit}
{...register("defaultRerankLimit")}
/>
<FieldError errors={[errors.defaultRerankLimit]} />
</FieldContent>
</Field>
<Field data-invalid={!!errors.answerMode}>
<FieldLabel htmlFor="kb-answer-mode"></FieldLabel>
<FieldContent>
<Controller
control={control}
name="answerMode"
render={({ field }) => (
<Select value={field.value} onValueChange={field.onChange}>
<SelectTrigger
id="kb-answer-mode"
aria-invalid={!!errors.answerMode}
>
<SelectValue placeholder="选择回答模式">
{field.value
? getEnumLabel(
KnowledgeAnswerModeLabels,
Number(field.value),
)
: undefined}
</SelectValue>
</SelectTrigger>
<SelectContent>
{getEnumOptions(KnowledgeAnswerModeLabels).map((option) => (
<SelectItem
key={option.value}
value={String(option.value)}
>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
)}
/>
<FieldError errors={[errors.answerMode]} />
</FieldContent>
</Field>
</div>
<Field data-invalid={!!errors.remark}>
<FieldLabel htmlFor="kb-remark"></FieldLabel>
<FieldContent>
<Textarea
id="kb-remark"
placeholder="备注信息"
rows={2}
aria-invalid={!!errors.remark}
{...register("remark")}
/>
<FieldError errors={[errors.remark]} />
</FieldContent>
</Field>
</form>
)}
</ProjectDialog>
);
}
@@ -0,0 +1,527 @@
"use client";
import {
DndContext,
KeyboardSensor,
MouseSensor,
TouchSensor,
closestCenter,
useSensor,
useSensors,
type DragEndEvent,
} from "@dnd-kit/core";
import {
SortableContext,
arrayMove,
sortableKeyboardCoordinates,
useSortable,
verticalListSortingStrategy,
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import {
CircleHelpIcon,
FileTextIcon,
MoreHorizontalIcon,
PencilIcon,
PlusIcon,
RefreshCwIcon,
SearchIcon,
Trash2Icon,
} from "lucide-react";
import type { CSSProperties } from "react";
import { useCallback, useEffect, useState } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuTrigger,
} from "@/components/ui/context-menu";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input";
import { ScrollArea } from "@/components/ui/scroll-area";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
createKnowledgeBase,
deleteKnowledgeBase,
fetchKnowledgeBases,
rebuildKnowledgeBaseIndex,
updateKnowledgeBase,
updateKnowledgeBaseSort,
type CreateKnowledgeBasePayload,
type KnowledgeBase,
} from "@/lib/api/admin";
import { getEnumLabel, getEnumOptions } from "@/lib/enums";
import { KnowledgeBaseType, StatusLabels } from "@/lib/generated/enums";
import { cn } from "@/lib/utils";
import { EditDialog } from "./knowledge-base-edit";
type KnowledgeBaseListProps = {
selectedKnowledgeBaseId: number | null;
onSelectKnowledgeBase: (knowledgeBase: KnowledgeBase | null) => void;
};
const statusOptions = [
{ value: "all", label: "全部状态" },
...getEnumOptions(StatusLabels),
] as const;
type SortableKnowledgeBaseCardProps = {
item: KnowledgeBase;
isSelected: boolean;
disabled: boolean;
onSelect: () => void;
onEdit: () => void;
onDelete: () => void;
onRebuildIndex: () => void;
deleteLoadingId: number | null;
rebuildIndexLoadingId: number | null;
};
function SortableKnowledgeBaseCard({
item,
isSelected,
disabled,
onSelect,
onEdit,
onDelete,
onRebuildIndex,
deleteLoadingId,
rebuildIndexLoadingId,
}: SortableKnowledgeBaseCardProps) {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({
id: item.id,
disabled,
});
const style: CSSProperties = {
transform: CSS.Transform.toString(transform),
transition,
};
return (
<ContextMenu>
<ContextMenuTrigger
ref={setNodeRef}
style={style}
className={cn(
"group flex items-center gap-1 rounded mx-2 px-2 py-1.5 text-sm transition-colors hover:bg-accent cursor-pointer",
isSelected && "bg-accent text-accent-foreground",
isDragging && "bg-muted/60 shadow-sm opacity-80",
)}
onClick={onSelect}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onSelect();
}
}}
{...attributes}
{...listeners}
>
{item.knowledgeType === KnowledgeBaseType.FAQ ? (
<CircleHelpIcon className="size-4 shrink-0 text-muted-foreground" />
) : (
<FileTextIcon className="size-4 shrink-0 text-muted-foreground" />
)}
<span className="min-w-0 flex-1 truncate">{item.name}</span>
<span className="shrink-0 text-xs text-muted-foreground">
{item.knowledgeType === "faq" ? item.faqCount : item.documentCount}
</span>
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button
variant="ghost"
size="icon"
className="size-6 opacity-0 group-hover:opacity-100"
/>
}
aria-label={`更多操作 ${item.name}`}
>
<MoreHorizontalIcon className="size-3.5" />
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-40 min-w-40">
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
onEdit();
}}
>
<PencilIcon className="mr-2 size-3.5" />
</DropdownMenuItem>
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
onRebuildIndex();
}}
>
<RefreshCwIcon className="mr-2 size-3.5" />
{rebuildIndexLoadingId === item.id ? "重建中..." : "重建索引"}
</DropdownMenuItem>
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
onDelete();
}}
className="text-destructive focus:text-destructive"
>
<Trash2Icon className="mr-2 size-3.5" />
{deleteLoadingId === item.id ? "删除中..." : "删除"}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuItem
onClick={(e) => {
e.stopPropagation();
onEdit();
}}
>
<PencilIcon className="mr-2 size-3.5" />
</ContextMenuItem>
<ContextMenuItem
onClick={(e) => {
e.stopPropagation();
onRebuildIndex();
}}
>
<RefreshCwIcon className="mr-2 size-3.5" />
{rebuildIndexLoadingId === item.id ? "重建中..." : "重建索引"}
</ContextMenuItem>
<ContextMenuItem
onClick={(e) => {
e.stopPropagation();
onDelete();
}}
variant="destructive"
>
<Trash2Icon className="mr-2 size-3.5" />
{deleteLoadingId === item.id ? "删除中..." : "删除"}
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
);
}
export function KnowledgeBaseList({
selectedKnowledgeBaseId,
onSelectKnowledgeBase,
}: KnowledgeBaseListProps) {
const [keywordInput, setKeywordInput] = useState("");
const [statusFilterInput, setStatusFilterInput] = useState("all");
const [keyword, setKeyword] = useState("");
const [statusFilter, setStatusFilter] = useState("all");
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [sorting, setSorting] = useState(false);
const [deleteLoadingId, setDeleteLoadingId] = useState<number | null>(null);
const [rebuildIndexLoadingId, setRebuildIndexLoadingId] = useState<
number | null
>(null);
const [dialogOpen, setDialogOpen] = useState(false);
const [editingItemId, setEditingItemId] = useState<number | null>(null);
const [knowledgeBases, setKnowledgeBases] = useState<KnowledgeBase[]>([]);
const sensors = useSensors(
useSensor(MouseSensor, {
activationConstraint: { distance: 8 },
}),
useSensor(TouchSensor, {
activationConstraint: { delay: 150, tolerance: 8 },
}),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
}),
);
const loadData = useCallback(async () => {
setLoading(true);
try {
const data = await fetchKnowledgeBases({
name: keyword.trim() || undefined,
status: statusFilter === "all" ? undefined : statusFilter,
limit: 1000,
});
setKnowledgeBases(data.results);
} catch (error) {
toast.error(error instanceof Error ? error.message : "加载知识库失败");
} finally {
setLoading(false);
}
}, [keyword, statusFilter]);
useEffect(() => {
void loadData();
}, [loadData]);
useEffect(() => {
if (
selectedKnowledgeBaseId === null &&
knowledgeBases.length > 0 &&
!loading
) {
onSelectKnowledgeBase(knowledgeBases[0]);
}
}, [selectedKnowledgeBaseId, knowledgeBases, loading, onSelectKnowledgeBase]);
function handleStatusFilterChange(value: string | null) {
setStatusFilterInput(value ?? "all");
}
function applyFilters() {
setKeyword(keywordInput);
setStatusFilter(statusFilterInput);
}
function handleFilterKeyDown(event: React.KeyboardEvent<HTMLInputElement>) {
if (event.key !== "Enter") {
return;
}
event.preventDefault();
applyFilters();
}
function openCreateDialog() {
setEditingItemId(null);
setDialogOpen(true);
}
function openEditDialog(item: KnowledgeBase) {
setEditingItemId(item.id);
setDialogOpen(true);
}
function handleDialogOpenChange(open: boolean) {
if (saving) {
return;
}
if (!open) {
setEditingItemId(null);
}
setDialogOpen(open);
}
async function handleSubmit(payload: CreateKnowledgeBasePayload) {
if (saving) {
return;
}
setSaving(true);
try {
if (editingItemId) {
await updateKnowledgeBase({
id: editingItemId,
...payload,
});
const editingItem = knowledgeBases.find(
(item) => item.id === editingItemId,
);
toast.success(`已更新知识库:${editingItem?.name || payload.name}`);
} else {
await createKnowledgeBase(payload);
toast.success(`已创建知识库:${payload.name}`);
}
setDialogOpen(false);
setEditingItemId(null);
await loadData();
} catch (error) {
toast.error(error instanceof Error ? error.message : "保存知识库失败");
} finally {
setSaving(false);
}
}
async function handleDelete(item: KnowledgeBase) {
setDeleteLoadingId(item.id);
try {
await deleteKnowledgeBase(item.id);
toast.success(`已删除知识库:${item.name}`);
if (selectedKnowledgeBaseId === item.id) {
onSelectKnowledgeBase(null);
}
await loadData();
} catch (error) {
toast.error(error instanceof Error ? error.message : "删除知识库失败");
} finally {
setDeleteLoadingId(null);
}
}
async function handleRebuildIndex(item: KnowledgeBase) {
setRebuildIndexLoadingId(item.id);
try {
await rebuildKnowledgeBaseIndex(item.id);
toast.success(`已开始重建知识库索引:${item.name}`);
} catch (error) {
toast.error(
error instanceof Error ? error.message : "重建知识库索引失败",
);
} finally {
setRebuildIndexLoadingId(null);
}
}
async function handleDragEnd(event: DragEndEvent) {
const { active, over } = event;
if (!over || active.id === over.id || sorting) {
return;
}
const previousResults = knowledgeBases;
const oldIndex = previousResults.findIndex((item) => item.id === active.id);
const newIndex = previousResults.findIndex((item) => item.id === over.id);
if (oldIndex < 0 || newIndex < 0) {
return;
}
const nextResults = arrayMove(previousResults, oldIndex, newIndex);
setKnowledgeBases(nextResults);
setSorting(true);
try {
await updateKnowledgeBaseSort(nextResults.map((item) => item.id));
toast.success("知识库排序已更新");
await loadData();
} catch (error) {
setKnowledgeBases(previousResults);
toast.error(
error instanceof Error ? error.message : "更新知识库排序失败",
);
} finally {
setSorting(false);
}
}
return (
<>
<div className="flex h-full flex-col border-r bg-muted/30">
<div className="flex flex-col gap-2 border-b bg-background p-4">
<div className="flex items-center justify-between">
<h2 className="text-sm font-semibold"></h2>
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="icon"
className="size-7"
onClick={() => void loadData()}
disabled={loading || sorting}
>
<RefreshCwIcon
className={loading || sorting ? "animate-spin" : "size-4"}
/>
</Button>
<Button
variant="ghost"
size="icon"
className="size-7"
onClick={openCreateDialog}
>
<PlusIcon className="size-4" />
</Button>
</div>
</div>
<div className="flex items-center gap-2">
<div className="relative flex-1">
<SearchIcon className="pointer-events-none absolute top-1/2 left-2 size-3.5 -translate-y-1/2 text-muted-foreground" />
<Input
value={keywordInput}
onChange={(event) => setKeywordInput(event.target.value)}
onKeyDown={handleFilterKeyDown}
placeholder="搜索知识库"
className="h-8 pl-8 text-xs"
/>
</div>
<Select
value={statusFilterInput}
onValueChange={handleStatusFilterChange}
>
<SelectTrigger className="h-8 w-28 text-xs">
<SelectValue>
{statusFilterInput === "all"
? "全部状态"
: getEnumLabel(StatusLabels, Number(statusFilterInput))}
</SelectValue>
</SelectTrigger>
<SelectContent>
{statusOptions.map((item) => (
<SelectItem
key={item.value}
value={item.value}
className="text-xs"
>
{item.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<ScrollArea className="flex-1">
<div className="py-1 space-y-0.5">
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={(event) => void handleDragEnd(event)}
>
<SortableContext
items={knowledgeBases.map((item) => item.id)}
strategy={verticalListSortingStrategy}
>
{knowledgeBases.map((item) => (
<SortableKnowledgeBaseCard
key={item.id}
item={item}
isSelected={selectedKnowledgeBaseId === item.id}
disabled={loading || sorting}
onSelect={() => onSelectKnowledgeBase(item)}
onEdit={() => openEditDialog(item)}
onDelete={() => void handleDelete(item)}
onRebuildIndex={() => void handleRebuildIndex(item)}
deleteLoadingId={deleteLoadingId}
rebuildIndexLoadingId={rebuildIndexLoadingId}
/>
))}
</SortableContext>
</DndContext>
{!loading && knowledgeBases.length === 0 ? (
<div className="py-8 text-center text-sm text-muted-foreground">
</div>
) : null}
</div>
</ScrollArea>
</div>
<EditDialog
open={dialogOpen}
saving={saving}
itemId={editingItemId}
onOpenChange={handleDialogOpenChange}
onSubmit={handleSubmit}
/>
</>
);
}
@@ -0,0 +1,262 @@
"use client"
import { useCallback, useEffect, useMemo, useState } from "react"
import { toast } from "sonner"
import {
fetchKnowledgeRetrieveLog,
type KnowledgeRetrieveHit,
type KnowledgeRetrieveLogDetail,
} from "@/lib/api/admin"
import { formatDateTime } from "@/lib/utils"
import { Badge } from "@/components/ui/badge"
import {
Drawer,
DrawerContent,
DrawerDescription,
DrawerHeader,
DrawerTitle,
} from "@/components/ui/drawer"
import { ScrollArea } from "@/components/ui/scroll-area"
import { Separator } from "@/components/ui/separator"
type RetrieveLogDetailDrawerProps = {
open: boolean
retrieveLogId: number | null
onOpenChange: (open: boolean) => void
}
function safeParseJSON(value: string) {
if (!value) {
return null
}
try {
return JSON.parse(value) as Record<string, unknown>
} catch {
return null
}
}
function CitationList({ hits }: { hits: KnowledgeRetrieveHit[] }) {
const citations = hits.filter((item) => item.isCitation)
if (citations.length === 0) {
return <div className="text-sm text-muted-foreground"></div>
}
return (
<div className="space-y-3">
{citations.map((item) => (
<div key={item.id} className="rounded-lg border p-3">
<div className="flex items-center gap-2">
<span className="font-medium">{getHitSourceLabel(item)}</span>
<Badge variant="outline">Chunk #{item.chunkNo}</Badge>
</div>
<div className="mt-1 text-xs text-muted-foreground">
{item.sectionPath || item.title || "未记录章节"}
</div>
<div className="mt-2 text-sm leading-6 whitespace-pre-wrap text-foreground/90">
{item.snippet || "-"}
</div>
</div>
))}
</div>
)
}
export function RetrieveLogDetailDrawer({
open,
retrieveLogId,
onOpenChange,
}: RetrieveLogDetailDrawerProps) {
const [loading, setLoading] = useState(false)
const [detail, setDetail] = useState<KnowledgeRetrieveLogDetail | null>(null)
const loadDetail = useCallback(async () => {
if (!retrieveLogId) {
setDetail(null)
return
}
setLoading(true)
try {
const data = await fetchKnowledgeRetrieveLog(retrieveLogId)
setDetail(data)
} catch (error) {
toast.error(error instanceof Error ? error.message : "加载检索日志详情失败")
} finally {
setLoading(false)
}
}, [retrieveLogId])
useEffect(() => {
if (open && retrieveLogId) {
void loadDetail()
}
}, [open, retrieveLogId, loadDetail])
const traceData = useMemo(() => safeParseJSON(detail?.log.traceData ?? ""), [detail?.log.traceData])
if (!open) {
return null
}
return (
<Drawer open={open} onOpenChange={onOpenChange} direction="right">
<DrawerContent className="max-w-3xl">
<DrawerHeader>
<DrawerTitle></DrawerTitle>
<DrawerDescription>
{detail?.log.question || (loading ? "加载中..." : "未找到检索日志")}
</DrawerDescription>
</DrawerHeader>
<ScrollArea className="h-[calc(100vh-6rem)] px-4 pb-6">
{!detail ? (
<div className="py-6 text-sm text-muted-foreground">
{loading ? "正在加载详情..." : "暂无详情数据"}
</div>
) : (
<div className="space-y-6 pb-6">
<section className="space-y-3">
<h3 className="text-sm font-semibold"></h3>
<div className="grid gap-3 rounded-lg border p-4 md:grid-cols-2">
<div>
<div className="text-xs text-muted-foreground"></div>
<div className="mt-1 text-sm">{detail.log.knowledgeBaseName || `#${detail.log.knowledgeBaseId}`}</div>
</div>
<div>
<div className="text-xs text-muted-foreground"></div>
<div className="mt-1 text-sm">{formatDateTime(detail.log.createdAt)}</div>
</div>
<div>
<div className="text-xs text-muted-foreground"> / </div>
<div className="mt-1 text-sm">{detail.log.channelName} / {detail.log.sceneName}</div>
</div>
<div>
<div className="text-xs text-muted-foreground">Request ID</div>
<div className="mt-1 break-all font-mono text-xs">{detail.log.requestId || "-"}</div>
</div>
<div className="md:col-span-2">
<div className="text-xs text-muted-foreground"></div>
<div className="mt-1 text-sm leading-6 whitespace-pre-wrap">{detail.log.question || "-"}</div>
</div>
<div className="md:col-span-2">
<div className="text-xs text-muted-foreground"></div>
<div className="mt-1 text-sm leading-6 whitespace-pre-wrap">{detail.log.rewriteQuestion || "-"}</div>
</div>
<div className="md:col-span-2">
<div className="text-xs text-muted-foreground"></div>
<div className="mt-1 text-sm leading-6 whitespace-pre-wrap">{detail.log.answer || "-"}</div>
</div>
</div>
</section>
<section className="space-y-3">
<h3 className="text-sm font-semibold"></h3>
<div className="grid gap-3 rounded-lg border p-4 md:grid-cols-3">
<Metric label="Chunk Provider" value={detail.log.chunkProvider || "-"} mono />
<Metric label="Target Tokens" value={detail.log.chunkTargetTokens} />
<Metric label="Max Tokens" value={detail.log.chunkMaxTokens} />
<Metric label="Overlap Tokens" value={detail.log.chunkOverlapTokens} />
<Metric label="Rerank" value={detail.log.rerankEnabled ? "已启用" : "未启用"} />
<Metric label="Rerank Limit" value={detail.log.rerankLimit} />
</div>
</section>
<section className="space-y-3">
<h3 className="text-sm font-semibold"></h3>
<div className="grid gap-3 rounded-lg border p-4 md:grid-cols-4">
<Metric label="回答状态" value={detail.log.answerStatusName} />
<Metric label="命中数" value={detail.log.hitCount} />
<Metric label="引用数" value={detail.log.citationCount} />
<Metric label="上下文 Chunk" value={detail.log.usedChunkCount} />
<Metric label="Top Score" value={detail.log.topScore.toFixed(4)} mono />
<Metric label="检索耗时" value={`${detail.log.retrieveMs} ms`} />
<Metric label="生成耗时" value={`${detail.log.generateMs} ms`} />
<Metric label="总耗时" value={`${detail.log.latencyMs} ms`} />
<Metric label="Prompt Tokens" value={detail.log.promptTokens} />
<Metric label="Completion Tokens" value={detail.log.completionTokens} />
<Metric label="模型" value={detail.log.modelName || "-"} mono />
<Metric label="会话 ID" value={detail.log.sessionId || "-"} mono />
</div>
</section>
<section className="space-y-3">
<h3 className="text-sm font-semibold"></h3>
<CitationList hits={detail.hits} />
</section>
<section className="space-y-3">
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold"></h3>
<div className="text-xs text-muted-foreground">{detail.hits.length} </div>
</div>
<div className="space-y-3">
{detail.hits.map((item) => (
<div key={item.id} className="rounded-lg border p-3">
<div className="flex flex-wrap items-center gap-2">
<Badge variant="outline">#{item.rankNo}</Badge>
<span className="font-medium">{getHitSourceLabel(item)}</span>
<Badge variant={item.usedInAnswer ? "default" : "secondary"}>
{item.usedInAnswer ? "已入上下文" : "未入上下文"}
</Badge>
{item.isCitation ? <Badge></Badge> : null}
</div>
<div className="mt-2 flex flex-wrap gap-x-4 gap-y-1 text-xs text-muted-foreground">
<span>{item.sectionPath || item.title || "-"}</span>
<span>Chunk #{item.chunkNo}</span>
<span>Provider{item.provider || "-"}</span>
<span>Score{item.score.toFixed(4)}</span>
<span>Rerank{item.rerankScore ? item.rerankScore.toFixed(4) : "-"}</span>
</div>
<Separator className="my-3" />
<div className="text-sm leading-6 whitespace-pre-wrap text-foreground/90">
{item.snippet || "-"}
</div>
</div>
))}
</div>
</section>
<section className="space-y-3">
<h3 className="text-sm font-semibold">TraceData</h3>
<div className="rounded-lg border bg-muted/20 p-4">
<pre className="overflow-x-auto text-xs leading-6 text-muted-foreground">
{traceData ? JSON.stringify(traceData, null, 2) : detail.log.traceData || "-"}
</pre>
</div>
</section>
</div>
)}
</ScrollArea>
</DrawerContent>
</Drawer>
)
}
function getHitSourceLabel(item: KnowledgeRetrieveHit) {
if (item.faqQuestion) {
return item.faqQuestion
}
if (item.documentTitle) {
return item.documentTitle
}
if (item.faqId > 0) {
return `FAQ #${item.faqId}`
}
return `文档 #${item.documentId}`
}
function Metric({
label,
value,
mono = false,
}: {
label: string
value: string | number
mono?: boolean
}) {
return (
<div>
<div className="text-xs text-muted-foreground">{label}</div>
<div className={`mt-1 text-sm ${mono ? "font-mono" : ""}`}>{String(value || value === 0 ? value : "-")}</div>
</div>
)
}
@@ -0,0 +1,292 @@
"use client"
import { useCallback, useEffect, useMemo, useState } from "react"
import { RefreshCwIcon, SearchIcon } from "lucide-react"
import { toast } from "sonner"
import {
fetchKnowledgeRetrieveLogs,
type KnowledgeRetrieveLog,
type PageResult,
} from "@/lib/api/admin"
import {
KnowledgeAnswerStatusLabels,
KnowledgeChunkProviderLabels,
KnowledgeRetrieveChannelLabels,
KnowledgeRetrieveSceneLabels,
} from "@/lib/generated/enums"
import { getEnumOptions } from "@/lib/enums"
import { formatDateTime } from "@/lib/utils"
import { ListPagination } from "@/components/list-pagination"
import { OptionCombobox } from "@/components/option-combobox"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table"
import { RetrieveLogDetailDrawer } from "./retrieve-log-detail"
type RetrieveLogListProps = {
knowledgeBaseId: number | null
}
const channelOptions = [
{ value: "all", label: "全部渠道" },
...getEnumOptions(KnowledgeRetrieveChannelLabels).map((item) => ({
value: String(item.value),
label: item.label,
})),
]
const sceneOptions = [
{ value: "all", label: "全部场景" },
...getEnumOptions(KnowledgeRetrieveSceneLabels).map((item) => ({
value: String(item.value),
label: item.label,
})),
]
const answerStatusOptions = [
{ value: "all", label: "全部回答状态" },
...getEnumOptions(KnowledgeAnswerStatusLabels).map((item) => ({
value: String(item.value),
label: item.label,
})),
]
const providerOptions = [
{ value: "all", label: "全部切分策略" },
...getEnumOptions(KnowledgeChunkProviderLabels).map((item) => ({
value: String(item.value),
label: item.label,
})),
]
const rerankOptions = [
{ value: "all", label: "全部 Rerank" },
{ value: "1", label: "已启用 Rerank" },
{ value: "0", label: "未启用 Rerank" },
]
function getAnswerStatusVariant(status: number): "default" | "secondary" | "outline" | "destructive" {
switch (status) {
case 1:
return "default"
case 2:
return "secondary"
case 3:
return "outline"
case 4:
return "destructive"
default:
return "outline"
}
}
export function RetrieveLogList({
knowledgeBaseId,
}: RetrieveLogListProps) {
const [questionInput, setQuestionInput] = useState("")
const [question, setQuestion] = useState("")
const [channel, setChannel] = useState("all")
const [scene, setScene] = useState("all")
const [answerStatus, setAnswerStatus] = useState("all")
const [chunkProvider, setChunkProvider] = useState("all")
const [rerankEnabled, setRerankEnabled] = useState("all")
const [page, setPage] = useState(1)
const [limit, setLimit] = useState(20)
const [loading, setLoading] = useState(false)
const [detailOpen, setDetailOpen] = useState(false)
const [selectedLogId, setSelectedLogId] = useState<number | null>(null)
const [result, setResult] = useState<PageResult<KnowledgeRetrieveLog>>({
results: [],
page: { page: 1, limit: 20, total: 0 },
})
const loadData = useCallback(async () => {
if (!knowledgeBaseId) {
setResult({ results: [], page: { page: 1, limit: 20, total: 0 } })
setLoading(false)
return
}
setLoading(true)
try {
const data = await fetchKnowledgeRetrieveLogs({
knowledgeBaseId,
question: question.trim() || undefined,
channel: channel === "all" ? undefined : channel,
scene: scene === "all" ? undefined : scene,
answerStatus: answerStatus === "all" ? undefined : Number(answerStatus),
chunkProvider: chunkProvider === "all" ? undefined : chunkProvider,
rerankEnabled: rerankEnabled === "all" ? undefined : Number(rerankEnabled),
page,
limit,
})
setResult(data)
} catch (error) {
toast.error(error instanceof Error ? error.message : "加载检索日志失败")
} finally {
setLoading(false)
}
}, [answerStatus, channel, chunkProvider, knowledgeBaseId, limit, page, question, rerankEnabled, scene])
useEffect(() => {
void loadData()
}, [loadData])
useEffect(() => {
setPage(1)
setSelectedLogId(null)
setDetailOpen(false)
}, [knowledgeBaseId])
const emptyStateText = useMemo(() => {
if (!knowledgeBaseId) {
return "请选择一个知识库查看检索日志"
}
if (loading) {
return "正在加载检索日志..."
}
return "当前知识库还没有检索日志"
}, [knowledgeBaseId, loading])
function applyFilters() {
setQuestion(questionInput)
setPage(1)
}
function handleQuestionKeyDown(event: React.KeyboardEvent<HTMLInputElement>) {
if (event.key !== "Enter") {
return
}
event.preventDefault()
applyFilters()
}
function handleOpenDetail(logId: number) {
setSelectedLogId(logId)
setDetailOpen(true)
}
if (!knowledgeBaseId) {
return (
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
{emptyStateText}
</div>
)
}
return (
<>
<div className="flex h-full flex-col">
<div className="flex flex-col gap-3 border-b bg-background px-6 py-2">
<div className="grid gap-3 xl:grid-cols-[minmax(0,1.8fr)_repeat(5,minmax(0,0.8fr))_auto]">
<div className="relative">
<SearchIcon className="pointer-events-none absolute top-1/2 left-3 size-4 -translate-y-1/2 text-muted-foreground" />
<Input
value={questionInput}
onChange={(event) => setQuestionInput(event.target.value)}
onKeyDown={handleQuestionKeyDown}
placeholder="按问题关键字筛选"
className="pl-9"
/>
</div>
<OptionCombobox value={channel} options={channelOptions} placeholder="选择渠道" onChange={setChannel} />
<OptionCombobox value={scene} options={sceneOptions} placeholder="选择场景" onChange={setScene} />
<OptionCombobox value={answerStatus} options={answerStatusOptions} placeholder="回答状态" onChange={setAnswerStatus} />
<OptionCombobox value={chunkProvider} options={providerOptions} placeholder="切分策略" onChange={setChunkProvider} />
<OptionCombobox value={rerankEnabled} options={rerankOptions} placeholder="Rerank" onChange={setRerankEnabled} />
<Button onClick={applyFilters}></Button>
</div>
</div>
<div className="min-h-0 flex-1 overflow-auto px-6 py-4">
<div className="overflow-hidden rounded-xl border">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-42"></TableHead>
<TableHead></TableHead>
<TableHead className="w-28"></TableHead>
<TableHead className="w-24 text-right"></TableHead>
<TableHead className="w-24 text-right">TopScore</TableHead>
<TableHead className="w-28">Provider</TableHead>
<TableHead className="w-24">Rerank</TableHead>
<TableHead className="w-24 text-right"></TableHead>
<TableHead className="w-28 text-right"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{result.results.length === 0 ? (
<TableRow>
<TableCell colSpan={9} className="h-32 text-center text-muted-foreground">
{emptyStateText}
</TableCell>
</TableRow>
) : (
result.results.map((item) => (
<TableRow
key={item.id}
className="cursor-pointer"
onClick={() => handleOpenDetail(item.id)}
>
<TableCell className="text-xs text-muted-foreground">{formatDateTime(item.createdAt)}</TableCell>
<TableCell>
<div className="space-y-1">
<div className="line-clamp-2 font-medium">{item.question || "-"}</div>
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
<span>{item.channelName}</span>
<span>{item.sceneName}</span>
{item.knowledgeBaseName ? <span>{item.knowledgeBaseName}</span> : null}
</div>
</div>
</TableCell>
<TableCell>
<Badge variant={getAnswerStatusVariant(item.answerStatus)}>
{item.answerStatusName}
</Badge>
</TableCell>
<TableCell className="text-right">{item.hitCount}</TableCell>
<TableCell className="text-right font-mono text-xs">{item.topScore.toFixed(4)}</TableCell>
<TableCell>{item.chunkProvider || "-"}</TableCell>
<TableCell>{item.rerankEnabled ? `是 (${item.rerankLimit})` : "否"}</TableCell>
<TableCell className="text-right">{item.citationCount}</TableCell>
<TableCell className="text-right">{item.latencyMs} ms</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
</div>
<div className="border-t px-6 py-4">
<ListPagination
page={result.page.page}
total={result.page.total}
limit={result.page.limit}
loading={loading}
onPageChange={setPage}
onLimitChange={(nextLimit) => {
setLimit(nextLimit)
setPage(1)
}}
/>
</div>
</div>
<RetrieveLogDetailDrawer
open={detailOpen}
retrieveLogId={selectedLogId}
onOpenChange={setDetailOpen}
/>
</>
)
}