chore: import clean Agent Desk snapshot (6/7)

This commit is contained in:
2026-10-16 12:02:48 +00:00
parent d215bf6303
commit 756ae33d05
278 changed files with 53808 additions and 0 deletions

View File

@@ -0,0 +1,232 @@
"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";
import { useI18n } from "@/i18n/provider";
import { getKnowledgeAnswerStatusLabel } from "@/lib/knowledge-i18n";
type DebugPanelProps = {
knowledgeBaseId: number | null;
};
export function DebugPanel({ knowledgeBaseId }: DebugPanelProps) {
const t = useI18n();
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(t("knowledge.selectKnowledgeBaseFirst"));
return;
}
if (!question.trim()) {
toast.error(t("knowledge.debugQuestionRequired"));
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(t("knowledge.searchCompleted", { count: data.hitCount }));
} catch (error) {
toast.error(error instanceof Error ? error.message : t("knowledge.searchFailed"));
} finally {
setSearching(false);
}
}
async function handleAnswer() {
if (!knowledgeBaseId) {
toast.error(t("knowledge.selectKnowledgeBaseFirst"));
return;
}
if (!question.trim()) {
toast.error(t("knowledge.debugQuestionRequired"));
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(t("knowledge.answerCompleted", {
status: getKnowledgeAnswerStatusLabel(data.answerStatus, data.answerStatusName, t),
}));
} catch (error) {
toast.error(error instanceof Error ? error.message : t("knowledge.answerFailed"));
} 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={t("knowledge.debugQuestionPlaceholder")}
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={t("knowledge.topKPlaceholder")} className="h-8" />
</div>
<div className="space-y-1.5">
<Label htmlFor="threshold" className="text-xs">{t("knowledge.scoreThreshold")}</Label>
<Input id="threshold" value={scoreThreshold} onChange={(event) => setScoreThreshold(event.target.value)} placeholder={t("knowledge.scoreThresholdPlaceholder")} className="h-8" />
</div>
<div className="space-y-1.5">
<Label htmlFor="rerank" className="text-xs">{t("knowledge.rerankLimitLabel")}</Label>
<Input id="rerank" value={rerankLimit} onChange={(event) => setRerankLimit(event.target.value)} placeholder={t("knowledge.rerankLimitPlaceholder")} 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 ? t("knowledge.searching") : t("knowledge.debugSearch")}
</Button>
<Button className="flex-1" onClick={() => void handleAnswer()} disabled={answering}>
<SparklesIcon className="mr-2 size-4" />
{answering ? t("knowledge.generating") : t("knowledge.debugAnswer")}
</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" />
{t("knowledge.answerResult")}
</CardTitle>
</CardHeader>
<CardContent className="space-y-3 text-sm">
<div className="flex flex-wrap items-center gap-2">
<Badge variant="secondary">
{getKnowledgeAnswerStatusLabel(answerResult.answerStatus, answerResult.answerStatusName, t)}
</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">{t("knowledge.citationSources")}</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">{t("knowledge.searchHits")}</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<div className="text-xs text-muted-foreground">
{t("knowledge.hitsSummary", { count: searchResult.hitCount, latency: searchResult.latencyMs })}
</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 `Document ${item.documentId ?? 0}`
}

View File

@@ -0,0 +1,314 @@
"use client"
import { useEffect, useMemo, useState } from "react"
import { zodResolver } from "@hookform/resolvers/zod"
import { Controller, type Resolver, useForm } from "react-hook-form"
import { z } from "zod/v4"
import { ProjectDialog } from "@/components/project-dialog"
import { ContentEditor } from "@/components/content-editor"
import { OptionCombobox } from "@/components/option-combobox"
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,
type KnowledgeDirectory,
fetchKnowledgeDocument,
fetchKnowledgeDirectories,
} from "@/lib/api/admin"
import {
KnowledgeDocumentContentType,
} from "@/lib/generated/enums"
import { useI18n } from "@/i18n/provider"
type DocumentEditDialogProps = {
open: boolean
saving: boolean
itemId: number | null
knowledgeBaseId: number | null
initialDirectoryId?: number
onOpenChange: (open: boolean) => void
onSubmit: (payload: CreateKnowledgeDocumentPayload) => Promise<void>
}
const emptyForm: EditForm = {
directoryId: "0",
title: "",
contentType: KnowledgeDocumentContentType.Markdown,
content: "",
}
type TFunction = (key: string, values?: Record<string, string | number>) => string
function createKnowledgeDocumentFormSchema(t: TFunction) {
return z.object({
directoryId: z.string().trim(),
title: z.string().trim().min(1, t("knowledge.documentTitleRequired")).max(255, t("knowledge.documentTitleMax")),
contentType: z.string().trim().min(1, t("knowledge.contentTypeRequired")),
content: z.string().trim().min(1, t("knowledge.contentRequired")),
})
}
type EditForm = {
directoryId: string
title: string
contentType: string
content: string
}
type DirectoryOption = { value: string; label: string }
function flattenDirectoryOptions(items: KnowledgeDirectory[], depth = 0): DirectoryOption[] {
return items.flatMap((item) => [
{ value: String(item.id), label: `${depth > 0 ? " " : ""}${item.name}` },
...flattenDirectoryOptions(item.children || [], depth + 1),
])
}
function buildForm(item: KnowledgeDocument | null, initialDirectoryId = 0): EditForm {
if (!item) {
return { ...emptyForm, directoryId: String(initialDirectoryId) }
}
return {
directoryId: String(item.directoryId || 0),
title: item.title,
contentType: item.contentType || KnowledgeDocumentContentType.Markdown,
content: item.content || "",
}
}
function buildPayload(form: EditForm, knowledgeBaseId: number): CreateKnowledgeDocumentPayload {
return {
knowledgeBaseId,
directoryId: Number(form.directoryId),
title: form.title.trim(),
contentType: form.contentType,
content: form.content.trim(),
}
}
export function DocumentEditDialog({
open,
saving,
itemId,
knowledgeBaseId,
initialDirectoryId = 0,
onOpenChange,
onSubmit,
}: DocumentEditDialogProps) {
if (!open || !knowledgeBaseId) {
return null
}
return (
<DocumentFormDialogBody
key={itemId ? `edit-${itemId}` : "create"}
itemId={itemId}
knowledgeBaseId={knowledgeBaseId}
initialDirectoryId={initialDirectoryId}
saving={saving}
onOpenChange={onOpenChange}
onSubmit={onSubmit}
/>
)
}
type DocumentFormDialogBodyProps = {
saving: boolean
itemId: number | null
knowledgeBaseId: number
initialDirectoryId: number
onOpenChange: (open: boolean) => void
onSubmit: (payload: CreateKnowledgeDocumentPayload) => Promise<void>
}
function DocumentFormDialogBody({
saving,
itemId,
knowledgeBaseId,
initialDirectoryId,
onOpenChange,
onSubmit,
}: DocumentFormDialogBodyProps) {
const t = useI18n()
const formId = "knowledge-document-edit-form"
const [loading, setLoading] = useState(false)
const [directories, setDirectories] = useState<KnowledgeDirectory[]>([])
const knowledgeDocumentFormSchema = useMemo(() => createKnowledgeDocumentFormSchema(t), [t])
const editFormResolver = useMemo(
() => zodResolver(knowledgeDocumentFormSchema) as Resolver<EditForm>,
[knowledgeDocumentFormSchema],
)
const form = useForm<EditForm>({
resolver: editFormResolver,
defaultValues: emptyForm,
})
const {
control,
handleSubmit,
reset,
register,
setValue,
watch,
formState: { errors },
} = form
const contentType = watch("contentType")
const content = watch("content")
const directoryOptions = useMemo(
() => [
{ value: "0", label: t("knowledge.rootContent") },
...flattenDirectoryOptions(directories),
],
[directories, t],
)
useEffect(() => {
async function loadDetail() {
if (!itemId) {
reset(buildForm(null, initialDirectoryId))
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, initialDirectoryId, reset])
useEffect(() => {
let cancelled = false
async function loadDirectories() {
try {
const data = await fetchKnowledgeDirectories(knowledgeBaseId)
if (!cancelled) {
setDirectories(data)
}
} catch (error) {
console.error("Failed to load knowledge directories:", error)
}
}
void loadDirectories()
return () => {
cancelled = true
}
}, [knowledgeBaseId])
async function onFormSubmit(values: EditForm) {
const payload = buildPayload({ ...values, contentType, content }, knowledgeBaseId)
await onSubmit(payload)
}
return (
<ProjectDialog
open={true}
onOpenChange={onOpenChange}
title={itemId ? t("knowledge.editDocumentTitle") : t("knowledge.createDocumentTitle")}
size="xl"
allowFullscreen
footer={
<>
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
disabled={saving}
>
{t("knowledge.cancel")}
</Button>
<Button type="submit" form={formId} disabled={saving || loading}>
{saving ? t("knowledge.saving") : itemId ? t("knowledge.save") : t("knowledge.create")}
</Button>
</>
}
>
{loading ? (
<div className="flex items-center justify-center py-12">
<div className="text-muted-foreground">{t("knowledge.loading")}</div>
</div>
) : (
<form id={formId} onSubmit={handleSubmit(onFormSubmit)} className="space-y-4">
<Field data-invalid={!!errors.directoryId}>
<FieldLabel>{t("knowledge.directory")}</FieldLabel>
<FieldContent>
<Controller
control={control}
name="directoryId"
render={({ field }) => (
<OptionCombobox
value={field.value}
onChange={(value) => field.onChange(value ?? "0")}
options={directoryOptions}
placeholder={t("knowledge.selectDirectory")}
searchPlaceholder={t("knowledge.searchDirectory")}
emptyText={t("knowledge.emptyDirectory")}
/>
)}
/>
<FieldError errors={[errors.directoryId]} />
</FieldContent>
</Field>
<Field data-invalid={!!errors.title}>
<FieldLabel htmlFor="doc-title">{t("knowledge.documentTitle")}</FieldLabel>
<FieldContent>
<Input
id="doc-title"
placeholder={t("knowledge.documentTitlePlaceholder")}
aria-invalid={!!errors.title}
{...register("title")}
/>
<FieldError errors={[errors.title]} />
</FieldContent>
</Field>
<Field data-invalid={!!errors.content}>
<FieldLabel htmlFor="doc-content">{t("knowledge.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={t("knowledge.contentPlaceholder")}
disabled={saving}
/>
)}
/>
<FieldError errors={[errors.content]} />
</FieldContent>
</Field>
</form>
)}
</ProjectDialog>
)
}

View File

@@ -0,0 +1,814 @@
"use client";
import {
FileTextIcon,
FolderInputIcon,
EyeIcon,
MoreHorizontalIcon,
PencilIcon,
SearchIcon,
Trash2Icon,
WrenchIcon,
XIcon,
} from "lucide-react";
import { useCallback, useEffect, useMemo, useState, type KeyboardEvent } from "react";
import { toast } from "sonner";
import { useConfirm } from "@/components/confirm-provider";
import {
useDashboardPagedList,
type DashboardPagedListFilter,
} from "@/components/dashboard/list";
import { ListPagination } from "@/components/list-pagination";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Checkbox } from "@/components/ui/checkbox";
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 { OptionCombobox } from "@/components/option-combobox";
import { ScrollArea } from "@/components/ui/scroll-area";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import {
batchDeleteKnowledgeDocuments,
batchMoveKnowledgeDocuments,
buildKnowledgeDocumentIndex,
createKnowledgeDocument,
deleteKnowledgeDocument,
fetchKnowledgeDocuments,
updateKnowledgeDocument,
type CreateKnowledgeDocumentPayload,
type KnowledgeDocumentListItem,
} from "@/lib/api/admin";
import { useI18n } from "@/i18n/provider";
import {
KnowledgeDocumentIndexStatus,
Status,
} from "@/lib/generated/enums";
import { cn, formatDateTime } from "@/lib/utils";
import { DocumentEditDialog } from "./document-edit";
import { KnowledgeContentDetailDialog } from "./knowledge-content-detail";
import { KnowledgeBulkMoveDialog } from "./knowledge-bulk-move-dialog";
import { KnowledgeDirectoryPanel } from "./knowledge-directory-panel";
import { SelectionCheckbox } from "./selection-checkbox";
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;
};
type TFunction = (key: string, values?: Record<string, string | number>) => string;
function getStatusOptions(t: TFunction) {
return [
{ value: "all", label: t("knowledge.allStatus") },
{ value: String(Status.Ok), label: t("knowledge.statusOk") },
{ value: String(Status.Disabled), label: t("knowledge.statusDisabled") },
{ value: String(Status.Deleted), label: t("knowledge.statusDeleted") },
];
}
function getIndexStatusOptions(t: TFunction) {
return [
{ value: "all", label: t("knowledge.allIndexStatus") },
{ value: KnowledgeDocumentIndexStatus.Pending, label: t("knowledge.indexPending") },
{ value: KnowledgeDocumentIndexStatus.Indexed, label: t("knowledge.indexIndexed") },
{ value: KnowledgeDocumentIndexStatus.Failed, label: t("knowledge.indexFailed") },
];
}
function getIndexStatusLabel(status: string, t: TFunction) {
if (status === KnowledgeDocumentIndexStatus.Pending) return t("knowledge.indexPending");
if (status === KnowledgeDocumentIndexStatus.Indexed) return t("knowledge.indexIndexed");
if (status === KnowledgeDocumentIndexStatus.Failed) return t("knowledge.indexFailed");
return status || "-";
}
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: KnowledgeDocumentListItem, t: TFunction) {
const badge = (
<Badge variant={getIndexStatusBadgeVariant(item.indexStatus)}>
{getIndexStatusLabel(item.indexStatus, t)}
</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>
)
}
const VIEW_MODE_STORAGE_KEY = "knowledge-document-view-mode";
export function DocumentList({ knowledgeBaseId, onActionStateChange }: DocumentListProps) {
const t = useI18n();
const confirm = useConfirm();
const [saving, setSaving] = useState(false);
const [moving, setMoving] = useState(false);
const [bulkMoveOpen, setBulkMoveOpen] = useState(false);
const [selectedIds, setSelectedIds] = useState<number[]>([]);
const [moveTargetIds, setMoveTargetIds] = useState<number[]>([]);
const [contextMenuDocumentId, setContextMenuDocumentId] = useState<number | null>(null);
const [detailItemId, setDetailItemId] = useState<number | null>(null);
const [detailOpen, setDetailOpen] = useState(false);
const [selectedDirectoryId, setSelectedDirectoryId] = useState<number | null>(null);
const [actionLoadingMap, setActionLoadingMap] = useState<Record<number, { rebuildIndex: boolean; delete: boolean }>>({});
const [dialogOpen, setDialogOpen] = useState(false);
const [editingItem, setEditingItem] = useState<KnowledgeDocumentListItem | 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 statusOptions = useMemo(() => getStatusOptions(t), [t]);
const indexStatusOptions = useMemo(() => getIndexStatusOptions(t), [t]);
useEffect(() => {
setSelectedDirectoryId(null);
setSelectedIds([]);
}, [knowledgeBaseId]);
const filters = useMemo<DashboardPagedListFilter[]>(() => [
{
name: "title",
defaultValue: "",
trim: true,
},
{
name: "status",
defaultValue: "all",
allValue: "all",
},
{
name: "indexStatus",
defaultValue: "all",
allValue: "all",
},
], []);
const fetchList = useCallback(async (query: Record<string, string | number | undefined>) => {
return fetchKnowledgeDocuments({
title: typeof query.title === "string" ? query.title : undefined,
status: typeof query.status === "string" ? query.status : undefined,
indexStatus: typeof query.indexStatus === "string" ? query.indexStatus : undefined,
knowledgeBaseId: knowledgeBaseId ?? 0,
directoryId: selectedDirectoryId === null ? undefined : selectedDirectoryId,
page: typeof query.page === "number" ? query.page : Number(query.page ?? 1),
limit: typeof query.limit === "number" ? query.limit : Number(query.limit ?? 20),
});
}, [knowledgeBaseId, selectedDirectoryId]);
const {
draftFilters,
setDraftFilter,
applyFilter,
applyFilters: applyDraftFilters,
loading,
result: documents,
loadData,
handlePageChange,
handleLimitChange,
} = useDashboardPagedList<KnowledgeDocumentListItem>({
filters,
fetchList,
enabled: Boolean(knowledgeBaseId),
reloadKey: `${knowledgeBaseId ?? 0}-${selectedDirectoryId ?? "all"}`,
loadFailed: t("knowledge.loadDocumentsFailed"),
});
const currentPageIds = useMemo(
() => documents.results.map((item) => item.id),
[documents.results],
);
const currentPageSelectedCount = useMemo(
() => currentPageIds.filter((id) => selectedIds.includes(id)).length,
[currentPageIds, selectedIds],
);
const allCurrentPageSelected =
currentPageIds.length > 0 && currentPageSelectedCount === currentPageIds.length;
useEffect(() => {
setSelectedIds((prev) => prev.filter((id) => currentPageIds.includes(id)));
}, [currentPageIds]);
useEffect(() => {
localStorage.setItem(VIEW_MODE_STORAGE_KEY, viewMode);
}, [viewMode]);
function handleStatusFilterChange(value: string | null) {
applyFilter("status", value ?? "all");
}
function handleIndexStatusFilterChange(value: string | null) {
applyFilter("indexStatus", value ?? "all");
}
function applyFilters() {
applyDraftFilters();
}
function handleFilterKeyDown(event: KeyboardEvent<HTMLInputElement>) {
if (event.key !== "Enter") {
return;
}
event.preventDefault();
applyFilters();
}
function toggleSelected(id: number, checked: boolean) {
setSelectedIds((prev) => {
if (checked) {
return prev.includes(id) ? prev : [...prev, id];
}
return prev.filter((currentId) => currentId !== id);
});
}
function toggleCurrentPage(checked: boolean) {
setSelectedIds(checked ? currentPageIds : []);
}
function openMoveDialog(ids: number[]) {
setMoveTargetIds(ids);
setBulkMoveOpen(true);
}
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: KnowledgeDocumentListItem) {
setEditingItem(item);
setDialogOpen(true);
}
function openDetailDialog(item: KnowledgeDocumentListItem) {
setDetailItemId(item.id);
setDetailOpen(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(t("knowledge.documentUpdated", { title: editingItem.title }));
} else {
await createKnowledgeDocument(payload);
toast.success(t("knowledge.documentCreated", { title: payload.title }));
}
setDialogOpen(false);
setEditingItem(null);
await loadData();
} catch (error) {
toast.error(error instanceof Error ? error.message : t("knowledge.documentSaveFailed"));
} finally {
setSaving(false);
}
}
async function handleDelete(item: KnowledgeDocumentListItem) {
setActionLoadingMap((prev) => ({ ...prev, [item.id]: { ...prev[item.id], delete: true } }));
try {
await deleteKnowledgeDocument(item.id);
toast.success(t("knowledge.documentDeleted", { title: item.title }));
await loadData();
} catch (error) {
toast.error(error instanceof Error ? error.message : t("knowledge.documentDeleteFailed"));
} finally {
setActionLoadingMap((prev) => ({ ...prev, [item.id]: { ...prev[item.id], delete: false } }));
}
}
async function handleBatchDelete() {
if (selectedIds.length === 0) {
return;
}
const confirmed = await confirm({
title: t("knowledge.batchDelete"),
description: t("knowledge.batchDeleteConfirm", { count: selectedIds.length }),
confirmText: t("knowledge.delete"),
variant: "destructive",
});
if (!confirmed) {
return;
}
setSaving(true);
try {
await batchDeleteKnowledgeDocuments(selectedIds);
toast.success(t("knowledge.batchDeleted", { count: selectedIds.length }));
setSelectedIds([]);
await loadData();
} catch (error) {
toast.error(error instanceof Error ? error.message : t("knowledge.batchDeleteFailed"));
} finally {
setSaving(false);
}
}
async function handleBatchMove(directoryId: number) {
const ids = moveTargetIds.length > 0 ? moveTargetIds : selectedIds;
if (!knowledgeBaseId || ids.length === 0) {
return;
}
setMoving(true);
try {
await batchMoveKnowledgeDocuments({
knowledgeBaseId,
directoryId,
ids,
});
toast.success(t("knowledge.batchMoved", { count: ids.length }));
setBulkMoveOpen(false);
setMoveTargetIds([]);
setSelectedIds([]);
await loadData();
} catch (error) {
toast.error(error instanceof Error ? error.message : t("knowledge.batchMoveFailed"));
} finally {
setMoving(false);
}
}
async function handleBuildIndex(item: KnowledgeDocumentListItem) {
setActionLoadingMap((prev) => ({ ...prev, [item.id]: { ...prev[item.id], rebuildIndex: true } }));
try {
await buildKnowledgeDocumentIndex(item.id);
toast.success(t("knowledge.documentIndexRebuilt", { title: item.title }));
await loadData();
} catch (error) {
toast.error(error instanceof Error ? error.message : t("knowledge.documentIndexRebuildFailed"));
} 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>{t("knowledge.selectKnowledgeBaseForDocuments")}</p>
</div>
);
}
return (
<>
<div className="flex h-full min-h-0">
<KnowledgeDirectoryPanel
knowledgeBaseId={knowledgeBaseId}
selectedDirectoryId={selectedDirectoryId}
onSelectDirectory={setSelectedDirectoryId}
onChanged={() => void loadData()}
/>
<div className="flex min-w-0 flex-1 flex-col">
<div className="flex flex-col gap-2 border-b bg-background px-6 py-2">
<div className="flex min-w-0 gap-2">
<div className="relative min-w-0 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={String(draftFilters.title ?? "")}
onChange={(event) => setDraftFilter("title", event.target.value)}
onKeyDown={handleFilterKeyDown}
placeholder={t("knowledge.searchDocumentTitle")}
className="h-8 pl-8 text-xs"
/>
</div>
<div className="w-28 shrink-0">
<OptionCombobox
value={String(draftFilters.status ?? "all")}
onChange={handleStatusFilterChange}
options={statusOptions}
placeholder={t("knowledge.allStatus")}
searchPlaceholder={t("knowledge.searchStatus")}
emptyText={t("knowledge.emptyStatus")}
/>
</div>
<div className="w-32 shrink-0">
<OptionCombobox
value={String(draftFilters.indexStatus ?? "all")}
onChange={handleIndexStatusFilterChange}
options={indexStatusOptions}
placeholder={t("knowledge.allIndexStatus")}
searchPlaceholder={t("knowledge.searchStatus")}
emptyText={t("knowledge.emptyStatus")}
/>
</div>
<Button
variant="outline"
size="sm"
className="h-8 shrink-0"
onClick={applyFilters}
disabled={loading}
>
<SearchIcon className="size-3.5" />
{t("knowledge.query")}
</Button>
</div>
</div>
<div className="relative 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.length > 0 ? (
<label className="mb-1 flex h-8 w-fit cursor-pointer items-center gap-2 rounded-md px-2 text-xs text-muted-foreground hover:bg-accent">
<Checkbox
checked={allCurrentPageSelected}
onCheckedChange={(checked) => toggleCurrentPage(Boolean(checked))}
aria-label={t("knowledge.selectCurrentPage")}
/>
<span>{t("knowledge.selectCurrentPage")}</span>
</label>
) : null}
{documents.results.map((item) => (
viewMode === "grid" ? (
<ContextMenu
key={item.id}
onOpenChange={(open) => setContextMenuDocumentId(open ? item.id : null)}
>
<ContextMenuTrigger className="w-full">
<div
className={cn(
"bg-background p-3 transition-colors hover:bg-accent w-full cursor-pointer",
contextMenuDocumentId === item.id && "bg-accent text-accent-foreground",
)}
onClick={() => openDetailDialog(item)}
>
<div className="flex items-start justify-between gap-2">
<div className="flex min-w-0 flex-1 items-start gap-2">
<SelectionCheckbox
checked={selectedIds.includes(item.id)}
onCheckedChange={(checked) => toggleSelected(item.id, Boolean(checked))}
aria-label={t("knowledge.selectItem", { name: item.title })}
className="mt-0.5"
/>
{/* <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, t)}
</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
? t("knowledge.indexedAt", { time: formatDateTime(item.indexedAt) })
: getIndexStatusLabel(item.indexStatus, t)}
</span>
</div>
</div>
</div>
<DropdownMenu>
<DropdownMenuTrigger
onClick={(event) => event.stopPropagation()}
render={
<Button
variant="ghost"
size="icon"
className="size-6"
/>
}
aria-label={t("knowledge.moreActions", { name: item.title })}
>
<MoreHorizontalIcon className="size-3.5" />
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-32 min-w-32">
<DropdownMenuItem onClick={() => openDetailDialog(item)}>
<EyeIcon className="mr-2 size-3.5" />
{t("knowledge.view")}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => openEditDialog(item)}>
<PencilIcon className="mr-2 size-3.5" />
{t("knowledge.edit")}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => openMoveDialog([item.id])}>
<FolderInputIcon className="mr-2 size-3.5" />
{t("knowledge.move")}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => void handleBuildIndex(item)}>
<WrenchIcon className="mr-2 size-3.5" />
{actionLoadingMap[item.id]?.rebuildIndex ? t("knowledge.running") : t("knowledge.rebuildIndex")}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => void handleDelete(item)}
className="text-destructive focus:text-destructive"
>
<Trash2Icon className="mr-2 size-3.5" />
{actionLoadingMap[item.id]?.delete ? t("knowledge.deleting") : t("knowledge.delete")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
</ContextMenuTrigger>
<ContextMenuContent className="w-40">
<ContextMenuItem onClick={() => openDetailDialog(item)}>
<EyeIcon className="mr-2 size-3.5" />
{t("knowledge.view")}
</ContextMenuItem>
<ContextMenuItem onClick={() => openEditDialog(item)}>
<PencilIcon className="mr-2 size-3.5" />
{t("knowledge.edit")}
</ContextMenuItem>
<ContextMenuItem onClick={() => openMoveDialog([item.id])}>
<FolderInputIcon className="mr-2 size-3.5" />
{t("knowledge.move")}
</ContextMenuItem>
<ContextMenuItem onClick={() => void handleBuildIndex(item)} disabled={actionLoadingMap[item.id]?.rebuildIndex}>
<WrenchIcon className="mr-2 size-3.5" />
{actionLoadingMap[item.id]?.rebuildIndex ? t("knowledge.running") : t("knowledge.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 ? t("knowledge.deleting") : t("knowledge.delete")}
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
) : (
<ContextMenu
key={item.id}
onOpenChange={(open) => setContextMenuDocumentId(open ? item.id : null)}
>
<ContextMenuTrigger className="w-full">
<div
className={cn(
"flex items-center gap-3 bg-background p-2 transition-colors hover:bg-accent w-full cursor-pointer",
contextMenuDocumentId === item.id && "bg-accent text-accent-foreground",
)}
onClick={() => openDetailDialog(item)}
>
<SelectionCheckbox
checked={selectedIds.includes(item.id)}
onCheckedChange={(checked) => toggleSelected(item.id, Boolean(checked))}
aria-label={t("knowledge.selectItem", { name: item.title })}
/>
{/* <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, t)}
</div>
<div className="mt-1 text-xs text-muted-foreground">
{item.indexStatus === KnowledgeDocumentIndexStatus.Indexed
? t("knowledge.indexTime", { time: formatDateTime(item.indexedAt) })
: item.indexError || getIndexStatusLabel(item.indexStatus, t)}
</div>
</div>
<div className="shrink-0 text-xs text-muted-foreground">
{formatDateTime(item.createdAt)}
</div>
<DropdownMenu>
<DropdownMenuTrigger
onClick={(event) => event.stopPropagation()}
render={
<Button
variant="ghost"
size="icon"
className="size-6"
/>
}
aria-label={t("knowledge.moreActions", { name: item.title })}
>
<MoreHorizontalIcon className="size-3.5" />
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-32 min-w-32">
<DropdownMenuItem onClick={() => openDetailDialog(item)}>
<EyeIcon className="mr-2 size-3.5" />
{t("knowledge.view")}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => openEditDialog(item)}>
<PencilIcon className="mr-2 size-3.5" />
{t("knowledge.edit")}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => openMoveDialog([item.id])}>
<FolderInputIcon className="mr-2 size-3.5" />
{t("knowledge.move")}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => void handleBuildIndex(item)}>
<WrenchIcon className="mr-2 size-3.5" />
{actionLoadingMap[item.id]?.rebuildIndex ? t("knowledge.running") : t("knowledge.rebuildIndex")}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => void handleDelete(item)}
className="text-destructive focus:text-destructive"
>
<Trash2Icon className="mr-2 size-3.5" />
{actionLoadingMap[item.id]?.delete ? t("knowledge.deleting") : t("knowledge.delete")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</ContextMenuTrigger>
<ContextMenuContent className="w-40">
<ContextMenuItem onClick={() => openDetailDialog(item)}>
<EyeIcon className="mr-2 size-3.5" />
{t("knowledge.view")}
</ContextMenuItem>
<ContextMenuItem onClick={() => openEditDialog(item)}>
<PencilIcon className="mr-2 size-3.5" />
{t("knowledge.edit")}
</ContextMenuItem>
<ContextMenuItem onClick={() => openMoveDialog([item.id])}>
<FolderInputIcon className="mr-2 size-3.5" />
{t("knowledge.move")}
</ContextMenuItem>
<ContextMenuItem onClick={() => void handleBuildIndex(item)} disabled={actionLoadingMap[item.id]?.rebuildIndex}>
<WrenchIcon className="mr-2 size-3.5" />
{actionLoadingMap[item.id]?.rebuildIndex ? t("knowledge.running") : t("knowledge.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 ? t("knowledge.deleting") : t("knowledge.delete")}
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
)
))}
{!loading && documents.results.length === 0 ? (
<div className="py-8 text-center text-sm text-muted-foreground">
{t("knowledge.emptyDocuments")}
</div>
) : null}
</div>
</ScrollArea>
{selectedIds.length > 0 ? (
<div className="pointer-events-none absolute inset-x-0 bottom-3 z-10 flex justify-center px-4">
<div className="pointer-events-auto flex items-center gap-2 rounded-md border bg-popover px-3 py-2 text-xs shadow-lg">
<span className="text-muted-foreground">
{t("knowledge.selectedCount", { count: selectedIds.length })}
</span>
<Button
type="button"
variant="outline"
size="sm"
className="h-7"
onClick={() => openMoveDialog(selectedIds)}
disabled={saving || moving}
>
<FolderInputIcon className="size-3.5" />
{t("knowledge.move")}
</Button>
<Button
type="button"
variant="outline"
size="sm"
className="h-7 text-destructive hover:text-destructive"
onClick={() => void handleBatchDelete()}
disabled={saving || moving}
>
<Trash2Icon className="size-3.5" />
{t("knowledge.delete")}
</Button>
<Button
type="button"
variant="ghost"
size="icon"
className="size-7"
onClick={() => setSelectedIds([])}
disabled={saving || moving}
aria-label={t("knowledge.clearSelection")}
>
<XIcon className="size-3.5" />
</Button>
</div>
</div>
) : null}
</div>
<div className="border-t px-6 py-3">
<ListPagination
page={documents.page.page}
limit={documents.page.limit}
total={documents.page.total}
loading={loading}
onPageChange={handlePageChange}
onLimitChange={handleLimitChange}
/>
</div>
</div>
</div>
<DocumentEditDialog
open={dialogOpen}
saving={saving}
itemId={editingItem?.id ?? null}
knowledgeBaseId={knowledgeBaseId}
initialDirectoryId={selectedDirectoryId ?? 0}
onOpenChange={handleDialogOpenChange}
onSubmit={handleSubmit}
/>
<KnowledgeContentDetailDialog
open={detailOpen}
type="document"
itemId={detailItemId}
onOpenChange={(open) => {
setDetailOpen(open);
if (!open) {
setDetailItemId(null);
}
}}
/>
<KnowledgeBulkMoveDialog
open={bulkMoveOpen}
knowledgeBaseId={knowledgeBaseId}
moving={moving}
selectedCount={moveTargetIds.length || selectedIds.length}
onOpenChange={(open) => {
setBulkMoveOpen(open);
if (!open) {
setMoveTargetIds([]);
}
}}
onSubmit={(directoryId) => void handleBatchMove(directoryId)}
/>
</>
);
}

View File

@@ -0,0 +1,289 @@
"use client";
import { useEffect, useMemo, 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 { OptionCombobox } from "@/components/option-combobox";
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,
fetchKnowledgeDirectories,
type CreateKnowledgeFAQPayload,
type KnowledgeDirectory,
type KnowledgeFAQ,
} from "@/lib/api/admin";
import { useI18n } from "@/i18n/provider";
type FAQEditDialogProps = {
open: boolean;
saving: boolean;
itemId: number | null;
knowledgeBaseId: number | null;
initialDirectoryId?: number;
onOpenChange: (open: boolean) => void;
onSubmit: (payload: CreateKnowledgeFAQPayload) => Promise<void>;
};
type TFunction = (key: string, values?: Record<string, string | number>) => string;
function createFormSchema(t: TFunction) {
return z.object({
directoryId: z.string().trim(),
question: z.string().trim().min(1, t("knowledge.faqQuestionRequired")).max(500, t("knowledge.faqQuestionMax")),
answer: z.string().trim().min(1, t("knowledge.faqAnswerRequired")),
similarQuestionsText: z.string(),
remark: z.string().trim().max(500, t("knowledge.remarkMax")),
});
}
type EditForm = {
directoryId: string;
question: string;
answer: string;
similarQuestionsText: string;
remark: string;
};
const emptyForm: EditForm = {
directoryId: "0",
question: "",
answer: "",
similarQuestionsText: "",
remark: "",
};
type DirectoryOption = { value: string; label: string };
function flattenDirectoryOptions(items: KnowledgeDirectory[], depth = 0): DirectoryOption[] {
return items.flatMap((item) => [
{ value: String(item.id), label: `${depth > 0 ? " " : ""}${item.name}` },
...flattenDirectoryOptions(item.children || [], depth + 1),
]);
}
function buildForm(item: KnowledgeFAQ | null, initialDirectoryId = 0): EditForm {
if (!item) {
return { ...emptyForm, directoryId: String(initialDirectoryId) };
}
return {
directoryId: String(item.directoryId || 0),
question: item.question,
answer: item.answer,
similarQuestionsText: (item.similarQuestions ?? []).join("\n"),
remark: item.remark ?? "",
};
}
function buildPayload(form: EditForm, knowledgeBaseId: number): CreateKnowledgeFAQPayload {
return {
knowledgeBaseId,
directoryId: Number(form.directoryId),
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,
initialDirectoryId = 0,
onOpenChange,
onSubmit,
}: FAQEditDialogProps) {
if (!open || !knowledgeBaseId) {
return null;
}
return (
<FAQEditDialogBody
key={itemId ? `edit-${itemId}` : "create"}
open={open}
saving={saving}
itemId={itemId}
knowledgeBaseId={knowledgeBaseId}
initialDirectoryId={initialDirectoryId}
onOpenChange={onOpenChange}
onSubmit={onSubmit}
/>
);
}
type FAQEditDialogBodyProps = {
open: boolean;
saving: boolean;
itemId: number | null;
knowledgeBaseId: number;
initialDirectoryId: number;
onOpenChange: (open: boolean) => void;
onSubmit: (payload: CreateKnowledgeFAQPayload) => Promise<void>;
};
function FAQEditDialogBody({
open,
saving,
itemId,
knowledgeBaseId,
initialDirectoryId,
onOpenChange,
onSubmit,
}: FAQEditDialogBodyProps) {
const t = useI18n();
const [loading, setLoading] = useState(false);
const [directories, setDirectories] = useState<KnowledgeDirectory[]>([]);
const formId = "knowledge-faq-edit-form";
const formSchema = useMemo(() => createFormSchema(t), [t]);
const resolver = useMemo(
() => zodResolver(formSchema) as Resolver<EditForm>,
[formSchema],
);
const form = useForm<EditForm>({
resolver,
defaultValues: emptyForm,
});
const {
handleSubmit,
reset,
register,
formState: { errors },
} = form;
const directoryOptions = useMemo(
() => [
{ value: "0", label: t("knowledge.rootContent") },
...flattenDirectoryOptions(directories),
],
[directories, t],
);
useEffect(() => {
async function loadDetail() {
if (!itemId) {
reset(buildForm(null, initialDirectoryId));
return;
}
setLoading(true);
try {
const data = await fetchKnowledgeFAQ(itemId);
reset(buildForm(data));
} finally {
setLoading(false);
}
}
if (open) {
void loadDetail();
}
}, [itemId, initialDirectoryId, open, reset]);
useEffect(() => {
if (!open) {
return;
}
let cancelled = false;
async function loadDirectories() {
try {
const data = await fetchKnowledgeDirectories(knowledgeBaseId);
if (!cancelled) {
setDirectories(data);
}
} catch (error) {
console.error("Failed to load knowledge directories:", error);
}
}
void loadDirectories();
return () => {
cancelled = true;
};
}, [knowledgeBaseId, open]);
async function onFormSubmit(values: EditForm) {
await onSubmit(buildPayload(values, knowledgeBaseId));
}
return (
<ProjectDialog
open={open}
onOpenChange={onOpenChange}
title={itemId ? t("knowledge.editFAQTitle") : t("knowledge.createFAQTitle")}
allowFullscreen
size="xl"
footer={
<>
<Button type="button" variant="outline" onClick={() => onOpenChange(false)} disabled={saving}>
{t("knowledge.cancel")}
</Button>
<Button type="submit" form={formId} disabled={saving || loading}>
{saving ? t("knowledge.saving") : itemId ? t("knowledge.save") : t("knowledge.create")}
</Button>
</>
}
>
{loading ? (
<div className="flex items-center justify-center py-12 text-muted-foreground">{t("knowledge.loading")}</div>
) : (
<form id={formId} onSubmit={handleSubmit(onFormSubmit)} className="space-y-4">
<Field data-invalid={!!errors.directoryId}>
<FieldLabel>{t("knowledge.directory")}</FieldLabel>
<FieldContent>
<OptionCombobox
value={form.watch("directoryId")}
onChange={(value) => form.setValue("directoryId", value ?? "0", { shouldDirty: true })}
options={directoryOptions}
placeholder={t("knowledge.selectDirectory")}
searchPlaceholder={t("knowledge.searchDirectory")}
emptyText={t("knowledge.emptyDirectory")}
/>
<FieldError errors={[errors.directoryId]} />
</FieldContent>
</Field>
<Field data-invalid={!!errors.question}>
<FieldLabel htmlFor="faq-question">{t("knowledge.standardQuestion")}</FieldLabel>
<FieldContent>
<Input id="faq-question" placeholder={t("knowledge.questionPlaceholder")} {...register("question")} />
<FieldError errors={[errors.question]} />
</FieldContent>
</Field>
<Field data-invalid={!!errors.answer}>
<FieldLabel htmlFor="faq-answer">{t("knowledge.answer")}</FieldLabel>
<FieldContent>
<Textarea id="faq-answer" rows={8} placeholder={t("knowledge.answerPlaceholder")} {...register("answer")} />
<FieldError errors={[errors.answer]} />
</FieldContent>
</Field>
<Field>
<FieldLabel htmlFor="faq-similar-questions">{t("knowledge.similarQuestions")}</FieldLabel>
<FieldContent>
<Textarea
id="faq-similar-questions"
rows={5}
placeholder={t("knowledge.similarQuestionsPlaceholder")}
{...register("similarQuestionsText")}
/>
</FieldContent>
</Field>
<Field data-invalid={!!errors.remark}>
<FieldLabel htmlFor="faq-remark">{t("knowledge.remark")}</FieldLabel>
<FieldContent>
<Textarea id="faq-remark" rows={3} placeholder={t("knowledge.remarkPlaceholder")} {...register("remark")} />
<FieldError errors={[errors.remark]} />
</FieldContent>
</Field>
</form>
)}
</ProjectDialog>
);
}

View File

@@ -0,0 +1,255 @@
"use client"
import { DownloadIcon, FileUpIcon, InfoIcon } from "lucide-react"
import { useMemo, useRef, useState } from "react"
import { toast } from "sonner"
import { OptionCombobox } from "@/components/option-combobox"
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 {
downloadKnowledgeFAQImportTemplate,
importKnowledgeFAQs,
type KnowledgeFAQImportMode,
type KnowledgeFAQImportResult,
} from "@/lib/api/admin"
import { useI18n } from "@/i18n/provider"
type FAQImportDialogProps = {
open: boolean
knowledgeBaseId: number | null
importing: boolean
onOpenChange: (open: boolean) => void
onImportingChange: (importing: boolean) => void
onImported: () => Promise<void>
}
export function FAQImportDialog({
open,
knowledgeBaseId,
importing,
onOpenChange,
onImportingChange,
onImported,
}: FAQImportDialogProps) {
const t = useI18n()
const fileInputRef = useRef<HTMLInputElement | null>(null)
const [file, setFile] = useState<File | null>(null)
const [mode, setMode] = useState<KnowledgeFAQImportMode>("append")
const [result, setResult] = useState<KnowledgeFAQImportResult | null>(null)
const modeOptions = useMemo(
() => [
{ value: "append", label: t("knowledge.importModeAppend") },
{ value: "overwrite", label: t("knowledge.importModeOverwrite") },
],
[t],
)
function resetState() {
setFile(null)
setMode("append")
setResult(null)
if (fileInputRef.current) {
fileInputRef.current.value = ""
}
}
function handleFileChange(event: React.ChangeEvent<HTMLInputElement>) {
const nextFile = event.target.files?.[0] ?? null
setResult(null)
if (!nextFile) {
setFile(null)
return
}
if (!nextFile.name.toLowerCase().endsWith(".xlsx")) {
setFile(null)
if (fileInputRef.current) {
fileInputRef.current.value = ""
}
toast.error(t("knowledge.importXlsxOnly"))
return
}
setFile(nextFile)
}
async function handleDownloadTemplate() {
try {
await downloadKnowledgeFAQImportTemplate()
} catch (error) {
toast.error(error instanceof Error ? error.message : t("knowledge.downloadTemplateFailed"))
}
}
async function handleImport() {
if (!knowledgeBaseId || !file || importing) {
return
}
onImportingChange(true)
setResult(null)
try {
const data = await importKnowledgeFAQs({
knowledgeBaseId,
mode,
file,
})
setResult(data)
await onImported()
toast.success(
t("knowledge.importFAQResultToast", {
created: data.created,
updated: data.updated,
skipped: data.skipped,
failed: data.failed,
}),
)
if (data.failed === 0) {
resetState()
onOpenChange(false)
}
} catch (error) {
toast.error(error instanceof Error ? error.message : t("knowledge.importFailed"))
} finally {
onImportingChange(false)
}
}
return (
<ProjectDialog
open={open}
onOpenChange={(nextOpen) => {
if (!nextOpen && !importing) {
resetState()
}
onOpenChange(nextOpen)
}}
title={t("knowledge.importFAQTitle")}
description={t("knowledge.importFAQDescription")}
size="lg"
footer={
<>
<Button type="button" variant="outline" onClick={() => void handleDownloadTemplate()}>
<DownloadIcon className="size-4" />
{t("knowledge.downloadTemplate")}
</Button>
<Button type="button" variant="outline" onClick={() => onOpenChange(false)} disabled={importing}>
{t("knowledge.cancel")}
</Button>
<Button type="button" onClick={() => void handleImport()} disabled={importing || !file}>
{importing ? t("knowledge.importing") : t("knowledge.startImport")}
</Button>
</>
}
>
<FieldGroup>
<Field>
<FieldLabel>{t("knowledge.importMode")}</FieldLabel>
<FieldContent>
<OptionCombobox
value={mode}
options={modeOptions}
placeholder={t("knowledge.selectImportMode")}
searchPlaceholder={t("knowledge.searchImportMode")}
emptyText={t("knowledge.emptyImportMode")}
disabled={importing}
onChange={(value) => setMode(value as KnowledgeFAQImportMode)}
/>
<FieldDescription>
{mode === "overwrite"
? t("knowledge.importModeOverwriteDescription")
: t("knowledge.importModeAppendDescription")}
</FieldDescription>
</FieldContent>
</Field>
<Field>
<FieldLabel htmlFor="faq-import-file">{t("knowledge.importFile")}</FieldLabel>
<FieldContent>
<div className="flex items-center gap-2">
<Input
id="faq-import-file"
ref={fileInputRef}
type="file"
accept=".xlsx,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
disabled={importing}
onChange={handleFileChange}
/>
<Button
type="button"
variant="outline"
disabled={importing}
onClick={() => fileInputRef.current?.click()}
>
<FileUpIcon className="size-4" />
{t("knowledge.chooseFile")}
</Button>
</div>
<FieldDescription>{t("knowledge.importFileDescription")}</FieldDescription>
</FieldContent>
</Field>
{file ? (
<div className="rounded-md border bg-muted/20 px-3 py-2 text-sm">
{t("knowledge.currentFile", { name: file.name })}
</div>
) : null}
<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" />
{t("knowledge.importHint")}
</div>
<div>{t("knowledge.importXlsxHint")}</div>
</div>
{result ? (
<div className="rounded-md border">
<div className="border-b px-4 py-3 text-sm font-medium">
{t("knowledge.importResult")}
</div>
<div className="grid grid-cols-2 gap-3 p-4 text-sm sm:grid-cols-5">
<ImportMetric label={t("knowledge.importTotal")} value={result.total} />
<ImportMetric label={t("knowledge.importCreated")} value={result.created} />
<ImportMetric label={t("knowledge.importUpdated")} value={result.updated} />
<ImportMetric label={t("knowledge.importSkipped")} value={result.skipped} />
<ImportMetric label={t("knowledge.importFailedCount")} value={result.failed} />
</div>
{result.errors.length > 0 ? (
<ScrollArea className="max-h-64 border-t">
<ul className="divide-y text-sm">
{result.errors.map((item, index) => (
<li key={`${item.row}-${index}`} className="px-4 py-2">
{t("knowledge.importRowFailed", {
row: item.row,
message: item.message,
})}
</li>
))}
</ul>
</ScrollArea>
) : null}
</div>
) : null}
</FieldGroup>
</ProjectDialog>
)
}
function ImportMetric({ label, value }: { label: string; value: number }) {
return (
<div className="rounded-md bg-muted/30 px-3 py-2">
<div className="text-xs text-muted-foreground">{label}</div>
<div className="mt-1 text-lg font-semibold">{value}</div>
</div>
)
}

View File

@@ -0,0 +1,653 @@
"use client";
import {
EyeIcon,
FolderInputIcon,
MoreHorizontalIcon,
PencilIcon,
SearchIcon,
Trash2Icon,
WrenchIcon,
XIcon,
} from "lucide-react";
import { useCallback, useEffect, useMemo, useState, type KeyboardEvent } from "react";
import { toast } from "sonner";
import { useConfirm } from "@/components/confirm-provider";
import {
useDashboardPagedList,
type DashboardPagedListFilter,
} from "@/components/dashboard/list";
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 { Checkbox } from "@/components/ui/checkbox";
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 {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import {
batchDeleteKnowledgeFAQs,
batchMoveKnowledgeFAQs,
buildKnowledgeFAQIndex,
createKnowledgeFAQ,
deleteKnowledgeFAQ,
fetchKnowledgeFAQs,
updateKnowledgeFAQ,
type CreateKnowledgeFAQPayload,
type KnowledgeFAQ,
} from "@/lib/api/admin";
import { KnowledgeDocumentIndexStatus } from "@/lib/generated/enums";
import { useI18n } from "@/i18n/provider";
import { cn, formatDateTime } from "@/lib/utils";
import { FAQEditDialog } from "./faq-edit";
import { FAQImportDialog } from "./faq-import-dialog";
import { KnowledgeContentDetailDialog } from "./knowledge-content-detail";
import { KnowledgeBulkMoveDialog } from "./knowledge-bulk-move-dialog";
import { KnowledgeDirectoryPanel } from "./knowledge-directory-panel";
import { SelectionCheckbox } from "./selection-checkbox";
type FAQListProps = {
knowledgeBaseId: number | null;
onActionStateChange?: (state: FAQListActionState) => void;
};
export type FAQListActionState = {
onRefresh: () => void;
onCreate: () => void;
onImport: () => void;
loading: boolean;
importing: boolean;
};
type TFunction = (key: string, values?: Record<string, string | number>) => string;
function getIndexStatusOptions(t: TFunction) {
return [
{ value: "all", label: t("knowledge.allIndexStatus") },
{ value: KnowledgeDocumentIndexStatus.Pending, label: t("knowledge.indexPending") },
{ value: KnowledgeDocumentIndexStatus.Indexed, label: t("knowledge.indexIndexed") },
{ value: KnowledgeDocumentIndexStatus.Failed, label: t("knowledge.indexFailed") },
];
}
function getIndexStatusLabel(status: string, t: TFunction) {
if (status === KnowledgeDocumentIndexStatus.Pending) return t("knowledge.indexPending");
if (status === KnowledgeDocumentIndexStatus.Indexed) return t("knowledge.indexIndexed");
if (status === KnowledgeDocumentIndexStatus.Failed) return t("knowledge.indexFailed");
return status || "-";
}
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, t: TFunction) {
const badge = (
<Badge variant={getIndexStatusBadgeVariant(item.indexStatus)}>
{getIndexStatusLabel(item.indexStatus, t)}
</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 t = useI18n();
const confirm = useConfirm();
const [saving, setSaving] = useState(false);
const [moving, setMoving] = useState(false);
const [bulkMoveOpen, setBulkMoveOpen] = useState(false);
const [selectedIds, setSelectedIds] = useState<number[]>([]);
const [moveTargetIds, setMoveTargetIds] = useState<number[]>([]);
const [contextMenuFAQId, setContextMenuFAQId] = useState<number | null>(null);
const [detailItemId, setDetailItemId] = useState<number | null>(null);
const [detailOpen, setDetailOpen] = useState(false);
const [importing, setImporting] = useState(false);
const [importDialogOpen, setImportDialogOpen] = useState(false);
const [selectedDirectoryId, setSelectedDirectoryId] = useState<number | null>(null);
const [dialogOpen, setDialogOpen] = useState(false);
const [editingItem, setEditingItem] = useState<KnowledgeFAQ | null>(null);
const [actionLoadingMap, setActionLoadingMap] = useState<Record<number, { rebuildIndex: boolean; delete: boolean }>>({});
const indexStatusOptions = useMemo(() => getIndexStatusOptions(t), [t]);
useEffect(() => {
setSelectedDirectoryId(null);
setSelectedIds([]);
}, [knowledgeBaseId]);
const filters = useMemo<DashboardPagedListFilter[]>(() => [
{
name: "question",
defaultValue: "",
trim: true,
},
{
name: "indexStatus",
defaultValue: "all",
allValue: "all",
},
], []);
const fetchList = useCallback(async (query: Record<string, string | number | undefined>) => {
return fetchKnowledgeFAQs({
knowledgeBaseId: knowledgeBaseId ?? 0,
directoryId: selectedDirectoryId === null ? undefined : selectedDirectoryId,
question: typeof query.question === "string" ? query.question : undefined,
indexStatus: typeof query.indexStatus === "string" ? query.indexStatus : undefined,
page: typeof query.page === "number" ? query.page : Number(query.page ?? 1),
limit: typeof query.limit === "number" ? query.limit : Number(query.limit ?? 20),
});
}, [knowledgeBaseId, selectedDirectoryId]);
const {
draftFilters,
setDraftFilter,
applyFilter,
applyFilters,
loading,
result: faqs,
loadData,
handlePageChange,
handleLimitChange,
} = useDashboardPagedList<KnowledgeFAQ>({
filters,
fetchList,
enabled: Boolean(knowledgeBaseId),
reloadKey: `${knowledgeBaseId ?? 0}-${selectedDirectoryId ?? "all"}`,
loadFailed: t("knowledge.loadFAQFailed"),
});
const currentPageIds = useMemo(
() => faqs.results.map((item) => item.id),
[faqs.results],
);
const currentPageSelectedCount = useMemo(
() => currentPageIds.filter((id) => selectedIds.includes(id)).length,
[currentPageIds, selectedIds],
);
const allCurrentPageSelected =
currentPageIds.length > 0 && currentPageSelectedCount === currentPageIds.length;
useEffect(() => {
setSelectedIds((prev) => prev.filter((id) => currentPageIds.includes(id)));
}, [currentPageIds]);
const openCreateDialog = useCallback(() => {
setEditingItem(null);
setDialogOpen(true);
}, []);
useEffect(() => {
onActionStateChange?.({
onRefresh: () => void loadData(),
onCreate: openCreateDialog,
onImport: () => setImportDialogOpen(true),
loading,
importing,
});
}, [loadData, loading, importing, onActionStateChange, openCreateDialog]);
function handleIndexStatusFilterChange(value: string | null) {
applyFilter("indexStatus", value ?? "all");
}
function handleFilterKeyDown(event: KeyboardEvent<HTMLInputElement>) {
if (event.key !== "Enter") {
return;
}
event.preventDefault();
applyFilters();
}
function toggleSelected(id: number, checked: boolean) {
setSelectedIds((prev) => {
if (checked) {
return prev.includes(id) ? prev : [...prev, id];
}
return prev.filter((currentId) => currentId !== id);
});
}
function toggleCurrentPage(checked: boolean) {
setSelectedIds(checked ? currentPageIds : []);
}
function openMoveDialog(ids: number[]) {
setMoveTargetIds(ids);
setBulkMoveOpen(true);
}
function openEditDialog(item: KnowledgeFAQ) {
setEditingItem(item);
setDialogOpen(true);
}
function openDetailDialog(item: KnowledgeFAQ) {
setDetailItemId(item.id);
setDetailOpen(true);
}
function handleDialogOpenChange(open: boolean) {
if (saving) {
return;
}
if (!open) {
setEditingItem(null);
}
setDialogOpen(open);
}
async function handleSubmit(payload: CreateKnowledgeFAQPayload) {
if (saving) {
return;
}
setSaving(true);
try {
if (editingItem) {
await updateKnowledgeFAQ({ id: editingItem.id, ...payload });
toast.success(t("knowledge.faqSaved"));
} else {
await createKnowledgeFAQ(payload);
toast.success(t("knowledge.faqSaved"));
}
setDialogOpen(false);
setEditingItem(null);
await loadData();
} catch (error) {
toast.error(error instanceof Error ? error.message : t("knowledge.faqSaveFailed"));
} 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(t("knowledge.faqDeleted"));
await loadData();
} catch (error) {
toast.error(error instanceof Error ? error.message : t("knowledge.faqDeleteFailed"));
} finally {
setActionLoadingMap((prev) => ({ ...prev, [item.id]: { ...prev[item.id], delete: false } }));
}
}
async function handleBatchDelete() {
if (selectedIds.length === 0) {
return;
}
const confirmed = await confirm({
title: t("knowledge.batchDelete"),
description: t("knowledge.batchDeleteConfirm", { count: selectedIds.length }),
confirmText: t("knowledge.delete"),
variant: "destructive",
});
if (!confirmed) {
return;
}
setSaving(true);
try {
await batchDeleteKnowledgeFAQs(selectedIds);
toast.success(t("knowledge.batchDeleted", { count: selectedIds.length }));
setSelectedIds([]);
await loadData();
} catch (error) {
toast.error(error instanceof Error ? error.message : t("knowledge.batchDeleteFailed"));
} finally {
setSaving(false);
}
}
async function handleBatchMove(directoryId: number) {
const ids = moveTargetIds.length > 0 ? moveTargetIds : selectedIds;
if (!knowledgeBaseId || ids.length === 0) {
return;
}
setMoving(true);
try {
await batchMoveKnowledgeFAQs({
knowledgeBaseId,
directoryId,
ids,
});
toast.success(t("knowledge.batchMoved", { count: ids.length }));
setBulkMoveOpen(false);
setMoveTargetIds([]);
setSelectedIds([]);
await loadData();
} catch (error) {
toast.error(error instanceof Error ? error.message : t("knowledge.batchMoveFailed"));
} finally {
setMoving(false);
}
}
async function handleBuildIndex(item: KnowledgeFAQ) {
setActionLoadingMap((prev) => ({ ...prev, [item.id]: { ...prev[item.id], rebuildIndex: true } }));
try {
await buildKnowledgeFAQIndex(item.id);
toast.success(t("knowledge.faqIndexRebuilt"));
await loadData();
} catch (error) {
toast.error(error instanceof Error ? error.message : t("knowledge.faqIndexRebuildFailed"));
} 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">
{t("knowledge.selectFAQBase")}
</div>
);
}
return (
<>
<div className="flex h-full min-h-0">
<KnowledgeDirectoryPanel
knowledgeBaseId={knowledgeBaseId}
selectedDirectoryId={selectedDirectoryId}
onSelectDirectory={setSelectedDirectoryId}
onChanged={() => void loadData()}
/>
<div className="flex min-w-0 flex-1 flex-col">
<div className="flex flex-col gap-2 border-b bg-background px-6 py-2">
<div className="flex min-w-0 gap-2">
<div className="relative min-w-0 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={String(draftFilters.question ?? "")}
onChange={(event) => setDraftFilter("question", event.target.value)}
onKeyDown={handleFilterKeyDown}
placeholder={t("knowledge.searchFAQ")}
className="h-8 pl-8 text-xs"
/>
</div>
<div className="w-32 shrink-0">
<OptionCombobox
value={String(draftFilters.indexStatus ?? "all")}
onChange={handleIndexStatusFilterChange}
options={indexStatusOptions}
placeholder={t("knowledge.allIndexStatus")}
searchPlaceholder={t("knowledge.searchStatus")}
emptyText={t("knowledge.emptyStatus")}
/>
</div>
<Button
variant="outline"
size="sm"
className="h-8 shrink-0"
onClick={applyFilters}
disabled={loading}
>
<SearchIcon className="size-3.5" />
{t("knowledge.query")}
</Button>
</div>
</div>
<div className="relative min-h-0 flex-1">
<ScrollArea className="h-full">
<div className="p-2 space-y-0.5">
{faqs.results.length > 0 ? (
<label className="mb-1 flex h-8 w-fit cursor-pointer items-center gap-2 rounded-md px-2 text-xs text-muted-foreground hover:bg-accent">
<Checkbox
checked={allCurrentPageSelected}
onCheckedChange={(checked) => toggleCurrentPage(Boolean(checked))}
aria-label={t("knowledge.selectCurrentPage")}
/>
<span>{t("knowledge.selectCurrentPage")}</span>
</label>
) : null}
{faqs.results.map((item) => (
<ContextMenu
key={item.id}
onOpenChange={(open) => setContextMenuFAQId(open ? item.id : null)}
>
<ContextMenuTrigger className="w-full">
<div
className={cn(
"flex items-center gap-3 bg-background p-2 transition-colors hover:bg-accent w-full cursor-pointer",
contextMenuFAQId === item.id && "bg-accent text-accent-foreground",
)}
onClick={() => openDetailDialog(item)}
>
<SelectionCheckbox
checked={selectedIds.includes(item.id)}
onCheckedChange={(checked) => toggleSelected(item.id, Boolean(checked))}
aria-label={t("knowledge.selectItem", { name: item.question })}
/>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<div className="truncate text-sm font-medium">{item.question}</div>
{renderIndexStatusBadge(item, t)}
</div>
<div className="mt-1 line-clamp-1 text-xs text-muted-foreground">
{item.answer}
</div>
</div>
<div className="hidden shrink-0 text-xs text-muted-foreground sm:block">
{formatDateTime(item.updatedAt)}
</div>
<DropdownMenu>
<DropdownMenuTrigger
onClick={(event) => event.stopPropagation()}
render={<Button variant="ghost" size="icon" className="size-6" />}
aria-label={t("knowledge.moreActions", { name: item.question })}
>
<MoreHorizontalIcon className="size-3.5" />
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-32 min-w-32">
<DropdownMenuItem onClick={() => openDetailDialog(item)}>
<EyeIcon className="mr-2 size-3.5" />
{t("knowledge.view")}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => openEditDialog(item)}>
<PencilIcon className="mr-2 size-3.5" />
{t("knowledge.edit")}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => openMoveDialog([item.id])}>
<FolderInputIcon className="mr-2 size-3.5" />
{t("knowledge.move")}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => void handleBuildIndex(item)}>
<WrenchIcon className="mr-2 size-3.5" />
{actionLoadingMap[item.id]?.rebuildIndex ? t("knowledge.running") : t("knowledge.rebuildIndex")}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => void handleDelete(item)}
className="text-destructive focus:text-destructive"
>
<Trash2Icon className="mr-2 size-3.5" />
{actionLoadingMap[item.id]?.delete ? t("knowledge.deleting") : t("knowledge.delete")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</ContextMenuTrigger>
<ContextMenuContent className="w-40">
<ContextMenuItem onClick={() => openDetailDialog(item)}>
<EyeIcon className="mr-2 size-3.5" />
{t("knowledge.view")}
</ContextMenuItem>
<ContextMenuItem onClick={() => openEditDialog(item)}>
<PencilIcon className="mr-2 size-3.5" />
{t("knowledge.edit")}
</ContextMenuItem>
<ContextMenuItem onClick={() => openMoveDialog([item.id])}>
<FolderInputIcon className="mr-2 size-3.5" />
{t("knowledge.move")}
</ContextMenuItem>
<ContextMenuItem onClick={() => void handleBuildIndex(item)} disabled={actionLoadingMap[item.id]?.rebuildIndex}>
<WrenchIcon className="mr-2 size-3.5" />
{actionLoadingMap[item.id]?.rebuildIndex ? t("knowledge.running") : t("knowledge.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 ? t("knowledge.deleting") : t("knowledge.delete")}
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
))}
{!loading && faqs.results.length === 0 ? (
<div className="py-8 text-center text-sm text-muted-foreground">
{t("knowledge.emptyFAQ")}
</div>
) : null}
</div>
</ScrollArea>
{selectedIds.length > 0 ? (
<div className="pointer-events-none absolute inset-x-0 bottom-3 z-10 flex justify-center px-4">
<div className="pointer-events-auto flex items-center gap-2 rounded-md border bg-popover px-3 py-2 text-xs shadow-lg">
<span className="text-muted-foreground">
{t("knowledge.selectedCount", { count: selectedIds.length })}
</span>
<Button
type="button"
variant="outline"
size="sm"
className="h-7"
onClick={() => openMoveDialog(selectedIds)}
disabled={saving || moving}
>
<FolderInputIcon className="size-3.5" />
{t("knowledge.move")}
</Button>
<Button
type="button"
variant="outline"
size="sm"
className="h-7 text-destructive hover:text-destructive"
onClick={() => void handleBatchDelete()}
disabled={saving || moving}
>
<Trash2Icon className="size-3.5" />
{t("knowledge.delete")}
</Button>
<Button
type="button"
variant="ghost"
size="icon"
className="size-7"
onClick={() => setSelectedIds([])}
disabled={saving || moving}
aria-label={t("knowledge.clearSelection")}
>
<XIcon className="size-3.5" />
</Button>
</div>
</div>
) : null}
</div>
<div className="border-t px-6 py-3">
<ListPagination
page={faqs.page.page}
limit={faqs.page.limit}
total={faqs.page.total}
loading={loading}
onPageChange={handlePageChange}
onLimitChange={handleLimitChange}
/>
</div>
</div>
</div>
<FAQEditDialog
open={dialogOpen}
saving={saving}
itemId={editingItem?.id ?? null}
knowledgeBaseId={knowledgeBaseId}
initialDirectoryId={selectedDirectoryId ?? 0}
onOpenChange={handleDialogOpenChange}
onSubmit={handleSubmit}
/>
<KnowledgeContentDetailDialog
open={detailOpen}
type="faq"
itemId={detailItemId}
onOpenChange={(open) => {
setDetailOpen(open);
if (!open) {
setDetailItemId(null);
}
}}
/>
<FAQImportDialog
open={importDialogOpen}
knowledgeBaseId={knowledgeBaseId}
importing={importing}
onOpenChange={setImportDialogOpen}
onImportingChange={setImporting}
onImported={async () => {
await loadData();
}}
/>
<KnowledgeBulkMoveDialog
open={bulkMoveOpen}
knowledgeBaseId={knowledgeBaseId}
moving={moving}
selectedCount={moveTargetIds.length || selectedIds.length}
onOpenChange={(open) => {
setBulkMoveOpen(open);
if (!open) {
setMoveTargetIds([]);
}
}}
onSubmit={(directoryId) => void handleBatchMove(directoryId)}
/>
</>
);
}

View File

@@ -0,0 +1,492 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { useEffect, useMemo, useState } from "react";
import { Controller, type 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 { Textarea } from "@/components/ui/textarea";
import {
fetchKnowledgeBase,
type CreateKnowledgeBasePayload,
type KnowledgeBase,
} from "@/lib/api/admin";
import { useI18n } from "@/i18n/provider";
import {
KnowledgeAnswerMode,
KnowledgeBaseType,
KnowledgeChunkProvider,
} 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: "",
};
type TFunction = (key: string, values?: Record<string, string | number>) => string;
function createKnowledgeBaseFormSchema(t: TFunction) {
return z.object({
name: z.string().trim().min(1, t("knowledge.nameRequired")).max(100, t("knowledge.nameMax")),
description: z.string().trim().max(500, t("knowledge.descriptionMax")),
knowledgeType: z.string().trim().min(1, t("knowledge.typeRequired")),
defaultTopK: z.string().trim().min(1, t("knowledge.topKRequired")),
defaultScoreThreshold: z.string().trim().min(1, t("knowledge.scoreRequired")),
defaultRerankLimit: z.string().trim().min(1, t("knowledge.rerankRequired")),
chunkProvider: z.string().trim().min(1, t("knowledge.chunkProviderRequired")),
chunkTargetTokens: z.string().trim().min(1, t("knowledge.targetTokensRequired")),
chunkMaxTokens: z.string().trim().min(1, t("knowledge.maxTokensRequired")),
chunkOverlapTokens: z.string().trim().min(1, t("knowledge.overlapTokensRequired")),
answerMode: z.string().trim().min(1, t("knowledge.answerModeRequired")),
remark: z.string().trim().max(500, t("knowledge.remarkMax")),
});
}
type EditForm = {
name: string;
description: string;
knowledgeType: string;
defaultTopK: string;
defaultScoreThreshold: string;
defaultRerankLimit: string;
chunkProvider: string;
chunkTargetTokens: string;
chunkMaxTokens: string;
chunkOverlapTokens: string;
answerMode: string;
remark: string;
};
function getKnowledgeTypeOptions(t: TFunction) {
return [
{ value: KnowledgeBaseType.Document, label: t("knowledge.typeDocument") },
{ value: KnowledgeBaseType.FAQ, label: t("knowledge.typeFAQ") },
];
}
function getChunkProviderOptions(t: TFunction) {
return [
{ value: KnowledgeChunkProvider.Fixed, label: t("knowledge.chunkFixed") },
{ value: KnowledgeChunkProvider.Structured, label: t("knowledge.chunkStructured") },
{ value: KnowledgeChunkProvider.Semantic, label: t("knowledge.chunkSemantic") },
];
}
function getAnswerModeOptions(t: TFunction) {
return [
{ value: String(KnowledgeAnswerMode.Strict), label: t("knowledge.answerStrict") },
{ value: String(KnowledgeAnswerMode.Assist), label: t("knowledge.answerAssist") },
];
}
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 t = useI18n();
const formId = "knowledge-base-edit-form";
const [loading, setLoading] = useState(false);
const knowledgeBaseFormSchema = useMemo(() => createKnowledgeBaseFormSchema(t), [t]);
const editFormResolver = useMemo(
() => zodResolver(knowledgeBaseFormSchema) as Resolver<EditForm>,
[knowledgeBaseFormSchema],
);
const knowledgeTypeOptions = useMemo(() => getKnowledgeTypeOptions(t), [t]);
const chunkProviderOptions = useMemo(() => getChunkProviderOptions(t), [t]);
const answerModeOptions = useMemo(() => getAnswerModeOptions(t), [t]);
const form = useForm<EditForm>({
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 ? t("knowledge.editBaseTitle") : t("knowledge.createBaseTitle")}
size="lg"
footer={
<>
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
disabled={saving || loading}
>
{t("knowledge.cancel")}
</Button>
<Button type="submit" form={formId} disabled={saving || loading}>
{saving ? t("knowledge.saving") : itemId ? t("knowledge.save") : t("knowledge.create")}
</Button>
</>
}
>
{loading ? (
<div className="flex items-center justify-center py-8">
<div className="text-sm text-muted-foreground">{t("knowledge.loading")}</div>
</div>
) : (
<form
id={formId}
onSubmit={handleSubmit(onFormSubmit)}
className="space-y-4"
>
<Field data-invalid={!!errors.knowledgeType}>
<FieldLabel htmlFor="kb-knowledge-type">{t("knowledge.knowledgeType")}</FieldLabel>
<FieldContent>
<Controller
control={control}
name="knowledgeType"
render={({ field }) => (
<OptionCombobox
value={field.value}
options={knowledgeTypeOptions}
placeholder={t("knowledge.selectKnowledgeType")}
searchPlaceholder={t("knowledge.searchKnowledgeType")}
emptyText={t("knowledge.emptyKnowledgeType")}
onChange={field.onChange}
/>
)}
/>
<FieldError errors={[errors.knowledgeType]} />
</FieldContent>
</Field>
<Field data-invalid={!!errors.name}>
<FieldLabel htmlFor="kb-name">{t("knowledge.name")}</FieldLabel>
<FieldContent>
<Input
id="kb-name"
placeholder={t("knowledge.namePlaceholder")}
aria-invalid={!!errors.name}
{...register("name")}
/>
<FieldError errors={[errors.name]} />
</FieldContent>
</Field>
<Field data-invalid={!!errors.description}>
<FieldLabel htmlFor="kb-description">{t("knowledge.description")}</FieldLabel>
<FieldContent>
<Textarea
id="kb-description"
placeholder={t("knowledge.descriptionPlaceholder")}
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">{t("knowledge.chunkProvider")}</FieldLabel>
<FieldContent>
<Controller
control={control}
name="chunkProvider"
render={({ field }) => (
<OptionCombobox
value={field.value}
options={chunkProviderOptions}
placeholder={t("knowledge.selectChunkProvider")}
searchPlaceholder={t("knowledge.searchChunkProvider")}
emptyText={t("knowledge.emptyChunkProvider")}
onChange={field.onChange}
/>
)}
/>
<FieldError errors={[errors.chunkProvider]} />
</FieldContent>
</Field>
<Field data-invalid={!!errors.chunkTargetTokens}>
<FieldLabel htmlFor="kb-chunk-target-tokens">
{t("knowledge.targetToken")}
</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">{t("knowledge.maxToken")}</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">
{t("knowledge.overlapToken")}
</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">{t("knowledge.defaultTopK")}</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">
{t("knowledge.defaultScoreThreshold")}
</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">
{t("knowledge.defaultRerankLimit")}
</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">{t("knowledge.answerMode")}</FieldLabel>
<FieldContent>
<Controller
control={control}
name="answerMode"
render={({ field }) => (
<OptionCombobox
value={field.value}
options={answerModeOptions}
placeholder={t("knowledge.selectAnswerMode")}
searchPlaceholder={t("knowledge.searchAnswerMode")}
emptyText={t("knowledge.emptyAnswerMode")}
onChange={field.onChange}
/>
)}
/>
<FieldError errors={[errors.answerMode]} />
</FieldContent>
</Field>
</div>
<Field data-invalid={!!errors.remark}>
<FieldLabel htmlFor="kb-remark">{t("knowledge.remark")}</FieldLabel>
<FieldContent>
<Textarea
id="kb-remark"
placeholder={t("knowledge.remarkPlaceholder")}
rows={2}
aria-invalid={!!errors.remark}
{...register("remark")}
/>
<FieldError errors={[errors.remark]} />
</FieldContent>
</Field>
</form>
)}
</ProjectDialog>
);
}

View File

@@ -0,0 +1,106 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { OptionCombobox } from "@/components/option-combobox";
import { ProjectDialog } from "@/components/project-dialog";
import { Button } from "@/components/ui/button";
import {
fetchKnowledgeDirectories,
type KnowledgeDirectory,
} from "@/lib/api/admin";
import { useI18n } from "@/i18n/provider";
type KnowledgeBulkMoveDialogProps = {
open: boolean;
knowledgeBaseId: number;
moving: boolean;
selectedCount: number;
onOpenChange: (open: boolean) => void;
onSubmit: (directoryId: number) => void;
};
function flattenDirectoryOptions(
directories: KnowledgeDirectory[],
): Array<{ value: string; label: string }> {
const result: Array<{ value: string; label: string }> = [];
for (const item of directories) {
result.push({ value: String(item.id), label: item.name });
for (const child of item.children ?? []) {
result.push({ value: String(child.id), label: `${item.name} / ${child.name}` });
}
}
return result;
}
export function KnowledgeBulkMoveDialog({
open,
knowledgeBaseId,
moving,
selectedCount,
onOpenChange,
onSubmit,
}: KnowledgeBulkMoveDialogProps) {
const t = useI18n();
const [directories, setDirectories] = useState<KnowledgeDirectory[]>([]);
const [targetDirectoryId, setTargetDirectoryId] = useState("0");
useEffect(() => {
if (!open) {
return;
}
setTargetDirectoryId("0");
fetchKnowledgeDirectories(knowledgeBaseId)
.then(setDirectories)
.catch((error) => {
console.error(error);
setDirectories([]);
});
}, [knowledgeBaseId, open]);
const directoryOptions = useMemo(() => [
{ value: "0", label: t("knowledge.rootContent") },
...flattenDirectoryOptions(directories),
], [directories, t]);
return (
<ProjectDialog
open={open}
onOpenChange={onOpenChange}
title={t("knowledge.batchMove")}
description={t("knowledge.batchMoveDescription", { count: selectedCount })}
size="sm"
footer={
<>
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
disabled={moving}
>
{t("knowledge.cancel")}
</Button>
<Button
type="button"
onClick={() => onSubmit(Number(targetDirectoryId))}
disabled={moving || selectedCount <= 0}
>
{moving ? t("knowledge.moving") : t("knowledge.move")}
</Button>
</>
}
>
<div className="space-y-2">
<div className="text-sm font-medium">{t("knowledge.targetDirectory")}</div>
<OptionCombobox
value={targetDirectoryId}
onChange={(value) => setTargetDirectoryId(value ?? "0")}
options={directoryOptions}
placeholder={t("knowledge.selectDirectory")}
searchPlaceholder={t("knowledge.searchDirectory")}
emptyText={t("knowledge.emptyDirectory")}
/>
</div>
</ProjectDialog>
);
}

View File

@@ -0,0 +1,213 @@
"use client";
import { useEffect, useState } from "react";
import { ProjectDialog } from "@/components/project-dialog";
import { SafeRichHTML } from "@/components/safe-rich-html";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
fetchKnowledgeDocument,
fetchKnowledgeFAQ,
type KnowledgeDocument,
type KnowledgeFAQ,
} from "@/lib/api/admin";
import { markdownToHtml } from "@/components/content-editor/convert";
import { KnowledgeDocumentContentType } from "@/lib/generated/enums";
import { useI18n } from "@/i18n/provider";
import { formatDateTime } from "@/lib/utils";
type KnowledgeContentDetailDialogProps = {
open: boolean;
type: "document" | "faq";
itemId: number | null;
onOpenChange: (open: boolean) => void;
};
type DetailFieldProps = {
label: string;
value: string;
};
function DetailField({ label, value }: DetailFieldProps) {
return (
<div className="min-w-0 space-y-1">
<div className="text-xs text-muted-foreground">{label}</div>
<div className="truncate text-sm">{value || "-"}</div>
</div>
);
}
function getDirectoryLabel(item: KnowledgeDocument | KnowledgeFAQ, rootLabel: string) {
return item.directoryPath || item.directoryName || (item.directoryId === 0 ? rootLabel : "-");
}
function renderDocumentContent(item: KnowledgeDocument) {
if (item.contentType === KnowledgeDocumentContentType.Markdown) {
return markdownToHtml(item.content || "");
}
return item.content || "";
}
export function KnowledgeContentDetailDialog({
open,
type,
itemId,
onOpenChange,
}: KnowledgeContentDetailDialogProps) {
const t = useI18n();
const [loading, setLoading] = useState(false);
const [documentDetail, setDocumentDetail] = useState<KnowledgeDocument | null>(null);
const [faqDetail, setFAQDetail] = useState<KnowledgeFAQ | null>(null);
useEffect(() => {
if (!open || !itemId) {
return;
}
const id = itemId;
let cancelled = false;
async function loadDetail() {
setLoading(true);
try {
if (type === "document") {
const data = await fetchKnowledgeDocument(id);
if (!cancelled) {
setDocumentDetail(data);
setFAQDetail(null);
}
return;
}
const data = await fetchKnowledgeFAQ(id);
if (!cancelled) {
setFAQDetail(data);
setDocumentDetail(null);
}
} finally {
if (!cancelled) {
setLoading(false);
}
}
}
void loadDetail();
return () => {
cancelled = true;
};
}, [itemId, open, type]);
const item = type === "document" ? documentDetail : faqDetail;
const title =
type === "document"
? documentDetail?.title || t("knowledge.viewDocumentTitle")
: faqDetail?.question || t("knowledge.viewFAQTitle");
return (
<ProjectDialog
open={open}
onOpenChange={onOpenChange}
title={title}
size="xl"
allowFullscreen
closeOnEsc
defaultFullscreen
footer={
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
{t("common.close")}
</Button>
}
>
{loading || !item ? (
<div className="flex items-center justify-center py-12 text-sm text-muted-foreground">
{t("knowledge.loading")}
</div>
) : (
<div className="space-y-5">
<div className="grid grid-cols-2 gap-4 md:grid-cols-4">
<DetailField
label={t("knowledge.directory")}
value={getDirectoryLabel(item, t("knowledge.rootContent"))}
/>
<div className="space-y-1">
<div className="text-xs text-muted-foreground">{t("knowledge.status")}</div>
<Badge variant="outline">{item.statusName || item.status}</Badge>
</div>
<div className="space-y-1">
<div className="text-xs text-muted-foreground">{t("knowledge.indexStatus")}</div>
<Badge variant="secondary">{item.indexStatusName || item.indexStatus}</Badge>
</div>
<DetailField
label={t("knowledge.createdAt")}
value={formatDateTime(item.createdAt)}
/>
<DetailField
label={t("knowledge.updatedAt")}
value={formatDateTime(item.updatedAt)}
/>
<DetailField
label={t("knowledge.createUser")}
value={item.createUserName || "-"}
/>
<DetailField
label={t("knowledge.updateUser")}
value={item.updateUserName || "-"}
/>
<DetailField
label={t("knowledge.indexedAtLabel")}
value={item.indexedAt ? formatDateTime(item.indexedAt) : "-"}
/>
</div>
{item.indexError ? (
<div className="rounded-md border border-destructive/30 bg-destructive/5 p-3 text-sm text-destructive">
{item.indexError}
</div>
) : null}
{type === "document" && documentDetail ? (
<div className="space-y-2">
<div className="flex items-center gap-2">
<div className="text-sm font-medium">{t("knowledge.content")}</div>
<Badge variant="outline">{documentDetail.contentType || "-"}</Badge>
</div>
<div className="rounded-md border bg-background p-4">
<SafeRichHTML html={renderDocumentContent(documentDetail)} />
</div>
</div>
) : null}
{type === "faq" && faqDetail ? (
<div className="space-y-5">
<div className="space-y-2">
<div className="text-sm font-medium">{t("knowledge.standardQuestion")}</div>
<div className="rounded-md border bg-muted/20 p-3 text-sm">{faqDetail.question}</div>
</div>
<div className="space-y-2">
<div className="text-sm font-medium">{t("knowledge.answer")}</div>
<pre className="whitespace-pre-wrap rounded-md border bg-muted/20 p-4 text-sm leading-6">
{faqDetail.answer || "-"}
</pre>
</div>
{faqDetail.similarQuestions.length > 0 ? (
<div className="space-y-2">
<div className="text-sm font-medium">{t("knowledge.similarQuestions")}</div>
<div className="flex flex-wrap gap-2">
{faqDetail.similarQuestions.map((question) => (
<Badge key={question} variant="outline">
{question}
</Badge>
))}
</div>
</div>
) : null}
{faqDetail.remark ? (
<div className="space-y-2">
<div className="text-sm font-medium">{t("knowledge.remark")}</div>
<div className="rounded-md border bg-muted/20 p-3 text-sm">{faqDetail.remark}</div>
</div>
) : null}
</div>
) : null}
</div>
)}
</ProjectDialog>
);
}

View File

@@ -0,0 +1,765 @@
"use client";
import {
DndContext,
KeyboardSensor,
MouseSensor,
TouchSensor,
closestCenter,
useDroppable,
useSensor,
useSensors,
type DragEndEvent,
} from "@dnd-kit/core";
import {
SortableContext,
sortableKeyboardCoordinates,
useSortable,
verticalListSortingStrategy,
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import {
ChevronDownIcon,
ChevronRightIcon,
FolderIcon,
FolderPlusIcon,
LayersIcon,
MoreHorizontalIcon,
PencilIcon,
PlusIcon,
Trash2Icon,
} from "lucide-react";
import type { CSSProperties, ReactNode } from "react";
import { useCallback, useEffect, useMemo, useRef, useState, type PointerEvent } from "react";
import { toast } from "sonner";
import { OptionCombobox } from "@/components/option-combobox";
import { ProjectDialog } from "@/components/project-dialog";
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 { Field, FieldContent, FieldLabel } from "@/components/ui/field";
import { Input } from "@/components/ui/input";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Textarea } from "@/components/ui/textarea";
import {
createKnowledgeDirectory,
deleteKnowledgeDirectory,
fetchKnowledgeDirectories,
updateKnowledgeDirectory,
updateKnowledgeDirectorySort,
type KnowledgeDirectory,
} from "@/lib/api/admin";
import { useI18n } from "@/i18n/provider";
import { cn } from "@/lib/utils";
import {
findDirectoryParentId,
moveDirectoryToParent,
moveDirectoryWithinParent,
} from "./knowledge-directory-sort";
type KnowledgeDirectoryPanelProps = {
knowledgeBaseId: number;
selectedDirectoryId: number | null;
onSelectDirectory: (directoryId: number | null) => void;
onChanged?: () => void;
};
type DirectoryDialogState = {
open: boolean;
id: number | null;
parentId: number;
name: string;
remark: string;
};
type DirectoryOption = { value: string; label: string };
const DIRECTORY_PANEL_WIDTH_STORAGE_KEY = "knowledge-directory-panel-width";
const DIRECTORY_PANEL_MIN_WIDTH = 180;
const DIRECTORY_PANEL_MAX_WIDTH = 360;
const DIRECTORY_PANEL_DEFAULT_WIDTH = 224;
const ROOT_DIRECTORY_DROP_ID = "knowledge-directory-root";
function rootDirectoryOptions(items: KnowledgeDirectory[]): DirectoryOption[] {
return items.map((item) => ({ value: String(item.id), label: item.name }));
}
function collectExpandedIds(items: KnowledgeDirectory[]) {
const ids = new Set<number>();
const walk = (nodes: KnowledgeDirectory[]) => {
for (const node of nodes) {
ids.add(node.id);
walk(node.children || []);
}
};
walk(items);
return ids;
}
export function KnowledgeDirectoryPanel({
knowledgeBaseId,
selectedDirectoryId,
onSelectDirectory,
onChanged,
}: KnowledgeDirectoryPanelProps) {
const t = useI18n();
const directoryNameInputRef = useRef<HTMLInputElement>(null);
const directoryNameFocusTimerRef = useRef<number | null>(null);
const [panelWidth, setPanelWidth] = useState(() => {
if (typeof window === "undefined") {
return DIRECTORY_PANEL_DEFAULT_WIDTH;
}
const saved = Number(localStorage.getItem(DIRECTORY_PANEL_WIDTH_STORAGE_KEY));
if (!Number.isFinite(saved)) {
return DIRECTORY_PANEL_DEFAULT_WIDTH;
}
return Math.min(DIRECTORY_PANEL_MAX_WIDTH, Math.max(DIRECTORY_PANEL_MIN_WIDTH, saved));
});
const [directories, setDirectories] = useState<KnowledgeDirectory[]>([]);
const [expandedIds, setExpandedIds] = useState<Set<number>>(new Set());
const [contextMenuDirectoryId, setContextMenuDirectoryId] = useState<number | null>(null);
const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false);
const [sorting, setSorting] = useState(false);
const [dialog, setDialog] = useState<DirectoryDialogState>({
open: false,
id: null,
parentId: 0,
name: "",
remark: "",
});
const parentOptions = useMemo(
() => [
{ value: "0", label: t("knowledge.rootDirectory") },
...rootDirectoryOptions(directories).filter((item) => item.value !== String(dialog.id ?? "")),
],
[directories, dialog.id, t],
);
const sensors = useSensors(
useSensor(MouseSensor, {
activationConstraint: { distance: 8 },
}),
useSensor(TouchSensor, {
activationConstraint: { delay: 150, tolerance: 8 },
}),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
}),
);
const loadDirectories = useCallback(async () => {
setLoading(true);
try {
const data = await fetchKnowledgeDirectories(knowledgeBaseId);
setDirectories(data);
setExpandedIds(collectExpandedIds(data));
} catch (error) {
toast.error(error instanceof Error ? error.message : t("knowledge.loadDirectoriesFailed"));
} finally {
setLoading(false);
}
}, [knowledgeBaseId, t]);
useEffect(() => {
void loadDirectories();
}, [loadDirectories]);
useEffect(() => {
localStorage.setItem(DIRECTORY_PANEL_WIDTH_STORAGE_KEY, String(panelWidth));
}, [panelWidth]);
useEffect(() => {
if (!dialog.open) {
return;
}
const frame = requestAnimationFrame(() => {
directoryNameFocusTimerRef.current = window.setTimeout(() => {
const input =
directoryNameInputRef.current ??
document.querySelector<HTMLInputElement>("[data-knowledge-directory-name-input='true']");
input?.focus({ preventScroll: true });
input?.select();
}, 80);
});
return () => {
cancelAnimationFrame(frame);
if (directoryNameFocusTimerRef.current !== null) {
window.clearTimeout(directoryNameFocusTimerRef.current);
directoryNameFocusTimerRef.current = null;
}
};
}, [dialog.open]);
function handleResizePointerDown(event: PointerEvent<HTMLDivElement>) {
event.preventDefault();
const startX = event.clientX;
const startWidth = panelWidth;
function handlePointerMove(moveEvent: globalThis.PointerEvent) {
const nextWidth = startWidth + moveEvent.clientX - startX;
setPanelWidth(
Math.min(DIRECTORY_PANEL_MAX_WIDTH, Math.max(DIRECTORY_PANEL_MIN_WIDTH, nextWidth)),
);
}
function handlePointerUp() {
window.removeEventListener("pointermove", handlePointerMove);
window.removeEventListener("pointerup", handlePointerUp);
document.body.style.cursor = "";
document.body.style.userSelect = "";
}
document.body.style.cursor = "col-resize";
document.body.style.userSelect = "none";
window.addEventListener("pointermove", handlePointerMove);
window.addEventListener("pointerup", handlePointerUp);
}
function toggleDirectory(id: number) {
setExpandedIds((current) => {
const next = new Set(current);
if (next.has(id)) {
next.delete(id);
} else {
next.add(id);
}
return next;
});
}
function openCreate(parentId = 0) {
setDialog({ open: true, id: null, parentId, name: "", remark: "" });
}
function openEdit(item: KnowledgeDirectory) {
setDialog({
open: true,
id: item.id,
parentId: item.parentId,
name: item.name,
remark: item.remark || "",
});
}
async function handleSubmit() {
const name = dialog.name.trim();
if (!name) {
toast.error(t("knowledge.directoryNameRequired"));
return;
}
setSaving(true);
try {
if (dialog.id) {
await updateKnowledgeDirectory({
id: dialog.id,
knowledgeBaseId,
parentId: dialog.parentId,
name,
remark: dialog.remark.trim(),
});
toast.success(t("knowledge.directoryUpdated", { name }));
} else {
await createKnowledgeDirectory({
knowledgeBaseId,
parentId: dialog.parentId,
name,
remark: dialog.remark.trim(),
});
toast.success(t("knowledge.directoryCreated", { name }));
}
setDialog((current) => ({ ...current, open: false }));
await loadDirectories();
onChanged?.();
} catch (error) {
toast.error(error instanceof Error ? error.message : t("knowledge.directorySaveFailed"));
} finally {
setSaving(false);
}
}
async function handleDelete(item: KnowledgeDirectory) {
setSaving(true);
try {
await deleteKnowledgeDirectory(item.id);
if (selectedDirectoryId === item.id) {
onSelectDirectory(null);
}
toast.success(t("knowledge.directoryDeleted", { name: item.name }));
await loadDirectories();
onChanged?.();
} catch (error) {
toast.error(error instanceof Error ? error.message : t("knowledge.directoryDeleteFailed"));
} finally {
setSaving(false);
}
}
async function handleDirectoryDragEnd(event: DragEndEvent) {
const { active, over } = event;
if (!over || active.id === over.id || sorting) {
return;
}
const activeId = Number(active.id);
if (!Number.isFinite(activeId)) {
return;
}
if (over.id === ROOT_DIRECTORY_DROP_ID) {
await handleDirectoryMoveToParent(activeId, 0);
return;
}
const overId = Number(over.id);
if (!Number.isFinite(overId)) {
return;
}
const activeParentId = findDirectoryParentId(directories, activeId);
const overParentId = findDirectoryParentId(directories, overId);
if (activeParentId === null || overParentId === null) {
return;
}
if (activeParentId !== overParentId) {
await handleDirectoryMoveToParent(activeId, overId);
return;
}
if (activeParentId === 0 && overParentId === 0 && event.delta.x > 24) {
await handleDirectoryMoveToParent(activeId, overId);
return;
}
const previousDirectories = directories;
const moved = moveDirectoryWithinParent(previousDirectories, activeParentId, activeId, overId);
if (!moved.changed) {
return;
}
setDirectories(moved.items);
setSorting(true);
try {
await updateKnowledgeDirectorySort({
knowledgeBaseId,
parentId: moved.parentId,
ids: moved.orderedIds,
});
toast.success(t("knowledge.directorySortUpdated"));
await loadDirectories();
onChanged?.();
} catch (error) {
setDirectories(previousDirectories);
toast.error(error instanceof Error ? error.message : t("knowledge.directorySortUpdateFailed"));
} finally {
setSorting(false);
}
}
async function handleDirectoryMoveToParent(activeId: number, targetParentId: number) {
const previousDirectories = directories;
const moved = moveDirectoryToParent(previousDirectories, activeId, targetParentId);
if (!moved.changed || !moved.item) {
return;
}
setDirectories(moved.items);
if (moved.parentId > 0) {
setExpandedIds((current) => new Set(current).add(moved.parentId));
}
setSorting(true);
try {
await updateKnowledgeDirectory({
id: moved.item.id,
knowledgeBaseId,
parentId: moved.parentId,
name: moved.item.name,
remark: moved.item.remark || "",
});
await updateKnowledgeDirectorySort({
knowledgeBaseId,
parentId: moved.parentId,
ids: moved.orderedIds,
});
toast.success(t("knowledge.directoryMoved"));
await loadDirectories();
onChanged?.();
} catch (error) {
setDirectories(previousDirectories);
toast.error(error instanceof Error ? error.message : t("knowledge.directoryMoveFailed"));
} finally {
setSorting(false);
}
}
return (
<>
<div
className="relative flex h-full min-h-0 shrink-0 flex-col border-r bg-muted/20"
style={{ width: panelWidth }}
>
<div className="flex h-[49px] items-center justify-between border-b bg-background px-3">
<div className="text-sm font-medium">{t("knowledge.directory")}</div>
<Button
variant="ghost"
size="icon"
className="size-7"
disabled={loading || saving}
onClick={() => openCreate(0)}
aria-label={t("knowledge.createDirectory")}
>
<FolderPlusIcon className="size-4" />
</Button>
</div>
<ScrollArea className="min-h-0 flex-1 overflow-hidden">
<div className="py-1">
<DirectoryStaticRow
icon={<LayersIcon className="size-4 text-muted-foreground" />}
label={t("knowledge.allContent")}
selected={selectedDirectoryId === null}
onClick={() => onSelectDirectory(null)}
/>
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={(event) => void handleDirectoryDragEnd(event)}
>
<RootDirectoryRow
selected={selectedDirectoryId === 0}
onClick={() => onSelectDirectory(0)}
t={t}
/>
<SortableContext
items={directories.map((item) => item.id)}
strategy={verticalListSortingStrategy}
>
{directories.map((item) => (
<DirectoryNode
key={item.id}
item={item}
depth={0}
expandedIds={expandedIds}
selectedDirectoryId={selectedDirectoryId}
contextMenuDirectoryId={contextMenuDirectoryId}
saving={saving || sorting}
onToggle={toggleDirectory}
onSelect={onSelectDirectory}
onContextMenuOpenChange={(directoryId, open) =>
setContextMenuDirectoryId(open ? directoryId : null)
}
onCreate={openCreate}
onEdit={openEdit}
onDelete={(directory) => void handleDelete(directory)}
t={t}
/>
))}
</SortableContext>
</DndContext>
</div>
</ScrollArea>
<div
className="absolute top-0 right-[-3px] z-10 h-full w-1.5 cursor-col-resize transition-colors hover:bg-primary/30"
onPointerDown={handleResizePointerDown}
role="separator"
aria-orientation="vertical"
aria-label={t("knowledge.resizeDirectoryPanel")}
/>
</div>
<ProjectDialog
open={dialog.open}
onOpenChange={(open) => setDialog((current) => ({ ...current, open }))}
title={dialog.id ? t("knowledge.editDirectory") : t("knowledge.createDirectory")}
size="sm"
footer={
<>
<Button
type="button"
variant="outline"
onClick={() => setDialog((current) => ({ ...current, open: false }))}
disabled={saving}
>
{t("knowledge.cancel")}
</Button>
<Button type="button" onClick={() => void handleSubmit()} disabled={saving}>
{saving ? t("knowledge.saving") : t("knowledge.save")}
</Button>
</>
}
>
<div className="space-y-4">
<Field>
<FieldLabel>{t("knowledge.parentDirectory")}</FieldLabel>
<FieldContent>
<OptionCombobox
value={String(dialog.parentId)}
onChange={(value) =>
setDialog((current) => ({ ...current, parentId: Number(value ?? 0) }))
}
options={parentOptions}
placeholder={t("knowledge.selectDirectory")}
searchPlaceholder={t("knowledge.searchDirectory")}
emptyText={t("knowledge.emptyDirectory")}
/>
</FieldContent>
</Field>
<Field>
<FieldLabel>{t("knowledge.directoryName")}</FieldLabel>
<FieldContent>
<Input
ref={directoryNameInputRef}
autoFocus
data-knowledge-directory-name-input="true"
value={dialog.name}
onChange={(event) =>
setDialog((current) => ({ ...current, name: event.target.value }))
}
placeholder={t("knowledge.directoryNamePlaceholder")}
/>
</FieldContent>
</Field>
<Field>
<FieldLabel>{t("knowledge.remark")}</FieldLabel>
<FieldContent>
<Textarea
value={dialog.remark}
onChange={(event) =>
setDialog((current) => ({ ...current, remark: event.target.value }))
}
rows={3}
placeholder={t("knowledge.remarkPlaceholder")}
/>
</FieldContent>
</Field>
</div>
</ProjectDialog>
</>
);
}
type DirectoryStaticRowProps = {
icon: ReactNode;
label: string;
selected: boolean;
draggingOver?: boolean;
onClick: () => void;
};
function DirectoryStaticRow({ icon, label, selected, draggingOver, onClick }: DirectoryStaticRowProps) {
return (
<button
type="button"
className={cn(
"flex w-full items-center gap-1 px-2 py-1.5 text-left text-sm hover:bg-accent",
selected && "bg-accent text-accent-foreground",
draggingOver && "bg-primary/10 text-accent-foreground",
)}
onClick={onClick}
>
<span className="size-5" />
{icon}
<span className="min-w-0 flex-1 truncate">{label}</span>
</button>
);
}
type RootDirectoryRowProps = {
selected: boolean;
onClick: () => void;
t: TFunction;
};
function RootDirectoryRow({ selected, onClick, t }: RootDirectoryRowProps) {
const { isOver, setNodeRef } = useDroppable({
id: ROOT_DIRECTORY_DROP_ID,
});
return (
<div ref={setNodeRef}>
<DirectoryStaticRow
icon={<FolderIcon className="size-4 text-muted-foreground" />}
label={t("knowledge.rootContent")}
selected={selected}
draggingOver={isOver}
onClick={onClick}
/>
</div>
);
}
type DirectoryNodeProps = {
item: KnowledgeDirectory;
depth: number;
expandedIds: Set<number>;
selectedDirectoryId: number | null;
contextMenuDirectoryId: number | null;
saving: boolean;
onToggle: (id: number) => void;
onSelect: (id: number) => void;
onContextMenuOpenChange: (id: number, open: boolean) => void;
onCreate: (parentId: number) => void;
onEdit: (item: KnowledgeDirectory) => void;
onDelete: (item: KnowledgeDirectory) => void;
t: TFunction;
};
type TFunction = (key: string, values?: Record<string, string | number>) => string;
function DirectoryNode({
item,
depth,
expandedIds,
selectedDirectoryId,
contextMenuDirectoryId,
saving,
onToggle,
onSelect,
onContextMenuOpenChange,
onCreate,
onEdit,
onDelete,
t,
}: DirectoryNodeProps) {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({
id: item.id,
disabled: saving,
});
const expanded = expandedIds.has(item.id);
const hasChildren = (item.children || []).length > 0;
const active = selectedDirectoryId === item.id || contextMenuDirectoryId === item.id;
const style: CSSProperties = {
transform: CSS.Transform.toString(transform),
transition,
};
return (
<div ref={setNodeRef} style={style}>
<ContextMenu onOpenChange={(open) => onContextMenuOpenChange(item.id, open)}>
<ContextMenuTrigger className="block">
<div
className={cn(
"group flex cursor-pointer items-center gap-1 px-2 py-1.5 text-sm hover:bg-accent",
active && "bg-accent text-accent-foreground",
isDragging && "bg-muted/60 shadow-sm opacity-80",
)}
style={{ paddingLeft: 8 + depth * 16 }}
onClick={() => onSelect(item.id)}
{...attributes}
{...listeners}
>
<Button
variant="ghost"
size="icon"
className="size-5 shrink-0"
disabled={!hasChildren}
onClick={(event) => {
event.stopPropagation();
onToggle(item.id);
}}
aria-label={expanded ? t("knowledge.collapseDirectory") : t("knowledge.expandDirectory")}
>
{expanded ? <ChevronDownIcon className="size-3.5" /> : <ChevronRightIcon className="size-3.5" />}
</Button>
<FolderIcon className="size-4 shrink-0 text-muted-foreground" />
<span className="min-w-0 flex-1 truncate text-left">
{item.name}
</span>
<DropdownMenu>
<DropdownMenuTrigger
onClick={(event) => event.stopPropagation()}
render={
<Button
variant="ghost"
size="icon"
className="size-6 opacity-0 group-hover:opacity-100"
disabled={saving}
/>
}
aria-label={t("knowledge.moreActions", { name: item.name })}
>
<MoreHorizontalIcon className="size-3.5" />
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-40 min-w-40">
{item.parentId === 0 ? (
<DropdownMenuItem onClick={() => onCreate(item.id)}>
<PlusIcon className="mr-2 size-3.5" />
{t("knowledge.createSubDirectory")}
</DropdownMenuItem>
) : null}
<DropdownMenuItem onClick={() => onEdit(item)}>
<PencilIcon className="mr-2 size-3.5" />
{t("knowledge.edit")}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => onDelete(item)}
className="text-destructive focus:text-destructive"
>
<Trash2Icon className="mr-2 size-3.5" />
{t("knowledge.delete")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</ContextMenuTrigger>
<ContextMenuContent className="w-40">
{item.parentId === 0 ? (
<ContextMenuItem onClick={() => onCreate(item.id)}>
<PlusIcon className="mr-2 size-3.5" />
{t("knowledge.createSubDirectory")}
</ContextMenuItem>
) : null}
<ContextMenuItem onClick={() => onEdit(item)}>
<PencilIcon className="mr-2 size-3.5" />
{t("knowledge.edit")}
</ContextMenuItem>
<ContextMenuItem
onClick={() => onDelete(item)}
variant="destructive"
>
<Trash2Icon className="mr-2 size-3.5" />
{t("knowledge.delete")}
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
{expanded
? (
<SortableContext
items={(item.children || []).map((child) => child.id)}
strategy={verticalListSortingStrategy}
>
{(item.children || []).map((child) => (
<DirectoryNode
key={child.id}
item={child}
depth={depth + 1}
expandedIds={expandedIds}
selectedDirectoryId={selectedDirectoryId}
contextMenuDirectoryId={contextMenuDirectoryId}
saving={saving}
onToggle={onToggle}
onSelect={onSelect}
onContextMenuOpenChange={onContextMenuOpenChange}
onCreate={onCreate}
onEdit={onEdit}
onDelete={onDelete}
t={t}
/>
))}
</SortableContext>
)
: null}
</div>
);
}

View File

@@ -0,0 +1,120 @@
import assert from "node:assert/strict"
import { describe, it } from "node:test"
import ts from "typescript"
import { readFile } from "node:fs/promises"
import vm from "node:vm"
function plain(value) {
return JSON.parse(JSON.stringify(value))
}
async function loadModule() {
const source = await readFile(
new URL("./knowledge-directory-sort.ts", import.meta.url),
"utf8"
)
const compiled = ts.transpileModule(source, {
compilerOptions: {
target: ts.ScriptTarget.ES2017,
module: ts.ModuleKind.CommonJS,
},
fileName: "knowledge-directory-sort.ts",
})
const sandbox = {
exports: {},
module: { exports: {} },
}
sandbox.exports = sandbox.module.exports
vm.runInNewContext(compiled.outputText, sandbox)
return sandbox.module.exports
}
const tree = [
{ id: 1, parentId: 0, name: "A", children: [
{ id: 11, parentId: 1, name: "A-1", children: [] },
{ id: 12, parentId: 1, name: "A-2", children: [] },
] },
{ id: 2, parentId: 0, name: "B", children: [] },
{ id: 3, parentId: 0, name: "C", children: [] },
]
describe("knowledge directory sorting", () => {
it("moves root directories within the same parent", async () => {
const { moveDirectoryWithinParent } = await loadModule()
const next = moveDirectoryWithinParent(tree, 0, 3, 1)
assert.deepEqual(plain(next.items).map((item) => item.id), [3, 1, 2])
assert.equal(next.changed, true)
assert.equal(next.parentId, 0)
assert.deepEqual(plain(next.orderedIds), [3, 1, 2])
})
it("moves child directories within their parent", async () => {
const { moveDirectoryWithinParent } = await loadModule()
const next = moveDirectoryWithinParent(tree, 1, 12, 11)
assert.deepEqual(plain(next.items[0].children).map((item) => item.id), [12, 11])
assert.equal(next.changed, true)
assert.equal(next.parentId, 1)
assert.deepEqual(plain(next.orderedIds), [12, 11])
})
it("does not move directories across parents", async () => {
const { findDirectoryParentId, moveDirectoryWithinParent } = await loadModule()
assert.equal(findDirectoryParentId(tree, 11), 1)
assert.equal(findDirectoryParentId(tree, 2), 0)
const next = moveDirectoryWithinParent(tree, 1, 11, 2)
assert.equal(next.changed, false)
assert.equal(next.items, tree)
assert.deepEqual(plain(next.orderedIds), [])
})
it("moves a child directory back to the root level", async () => {
const { moveDirectoryToParent } = await loadModule()
const next = moveDirectoryToParent(tree, 11, 0)
assert.equal(next.changed, true)
assert.equal(next.parentId, 0)
assert.equal(next.item?.parentId, 0)
assert.deepEqual(plain(next.items).map((item) => item.id), [1, 2, 3, 11])
assert.deepEqual(plain(next.items[0].children).map((item) => item.id), [12])
assert.deepEqual(plain(next.orderedIds), [1, 2, 3, 11])
})
it("moves a root directory without children under another root directory", async () => {
const { moveDirectoryToParent } = await loadModule()
const next = moveDirectoryToParent(tree, 3, 2)
assert.equal(next.changed, true)
assert.equal(next.parentId, 2)
assert.equal(next.item?.parentId, 2)
assert.deepEqual(plain(next.items).map((item) => item.id), [1, 2])
assert.deepEqual(plain(next.items[1].children).map((item) => item.id), [3])
assert.deepEqual(plain(next.orderedIds), [3])
})
it("rejects moving a directory with children under another directory", async () => {
const { moveDirectoryToParent } = await loadModule()
const next = moveDirectoryToParent(tree, 1, 2)
assert.equal(next.changed, false)
assert.equal(next.items, tree)
})
it("rejects moving a directory under a child directory", async () => {
const { moveDirectoryToParent } = await loadModule()
const next = moveDirectoryToParent(tree, 3, 11)
assert.equal(next.changed, false)
assert.equal(next.items, tree)
})
})

View File

@@ -0,0 +1,233 @@
export type SortableKnowledgeDirectory = {
id: number
parentId: number
children?: SortableKnowledgeDirectory[]
}
export type MoveDirectoryResult<T extends SortableKnowledgeDirectory> = {
items: T[]
changed: boolean
parentId: number
orderedIds: number[]
}
export type MoveDirectoryToParentResult<T extends SortableKnowledgeDirectory> =
MoveDirectoryResult<T> & {
item: T | null
}
function arrayMove<T>(items: T[], fromIndex: number, toIndex: number) {
const next = [...items]
const [item] = next.splice(fromIndex, 1)
next.splice(toIndex, 0, item)
return next
}
export function findDirectoryParentId<T extends SortableKnowledgeDirectory>(
items: T[],
id: number,
): number | null {
for (const item of items) {
if (item.id === id) {
return item.parentId
}
const childParentId = findDirectoryParentId(item.children as T[] | undefined ?? [], id)
if (childParentId !== null) {
return childParentId
}
}
return null
}
export function findDirectory<T extends SortableKnowledgeDirectory>(
items: T[],
id: number,
): T | null {
for (const item of items) {
if (item.id === id) {
return item
}
const child = findDirectory(item.children as T[] | undefined ?? [], id)
if (child !== null) {
return child
}
}
return null
}
export function moveDirectoryToParent<T extends SortableKnowledgeDirectory>(
items: T[],
activeId: number,
targetParentId: number,
): MoveDirectoryToParentResult<T> {
if (activeId === targetParentId) {
return unchangedParentMove(items, targetParentId)
}
const activeItem = findDirectory(items, activeId)
const activeParentId = findDirectoryParentId(items, activeId)
if (!activeItem || activeParentId === null || activeParentId === targetParentId) {
return unchangedParentMove(items, targetParentId)
}
if (targetParentId > 0) {
const targetParent = findDirectory(items, targetParentId)
if (!targetParent || targetParent.parentId !== 0 || (activeItem.children?.length ?? 0) > 0) {
return unchangedParentMove(items, targetParentId)
}
}
const removed = removeDirectory(items, activeId)
if (!removed.item) {
return unchangedParentMove(items, targetParentId)
}
const movedItem = { ...removed.item, parentId: targetParentId } as T
const appended = appendDirectoryToParent(removed.items, targetParentId, movedItem)
if (!appended.changed) {
return unchangedParentMove(items, targetParentId)
}
return {
items: appended.items,
changed: true,
parentId: targetParentId,
orderedIds: appended.siblings.map((item) => item.id),
item: movedItem,
}
}
export function moveDirectoryWithinParent<T extends SortableKnowledgeDirectory>(
items: T[],
parentId: number,
activeId: number,
overId: number,
): MoveDirectoryResult<T> {
if (activeId === overId) {
return { items, changed: false, parentId, orderedIds: [] }
}
if (parentId === 0) {
return moveSiblingList(items, parentId, activeId, overId)
}
let changed = false
let orderedIds: number[] = []
const nextItems = items.map((item) => {
if (item.id === parentId) {
const moved = moveSiblingList((item.children as T[] | undefined) ?? [], parentId, activeId, overId)
changed = moved.changed
orderedIds = moved.orderedIds
return { ...item, children: moved.items }
}
if (item.children?.length) {
const moved = moveDirectoryWithinParent(item.children as T[], parentId, activeId, overId)
if (moved.changed) {
changed = true
orderedIds = moved.orderedIds
return { ...item, children: moved.items }
}
}
return item
})
return {
items: changed ? nextItems : items,
changed,
parentId,
orderedIds,
}
}
function unchangedParentMove<T extends SortableKnowledgeDirectory>(
items: T[],
parentId: number,
): MoveDirectoryToParentResult<T> {
return { items, changed: false, parentId, orderedIds: [], item: null }
}
function removeDirectory<T extends SortableKnowledgeDirectory>(
items: T[],
id: number,
): { items: T[]; item: T | null } {
let removedItem: T | null = null
const nextItems: T[] = []
for (const item of items) {
if (item.id === id) {
removedItem = item
continue
}
if (item.children?.length) {
const removed = removeDirectory(item.children as T[], id)
if (removed.item) {
removedItem = removed.item
nextItems.push({ ...item, children: removed.items } as T)
continue
}
}
nextItems.push(item)
}
return {
items: removedItem ? nextItems : items,
item: removedItem,
}
}
function appendDirectoryToParent<T extends SortableKnowledgeDirectory>(
items: T[],
parentId: number,
item: T,
): { items: T[]; changed: boolean; siblings: T[] } {
if (parentId === 0) {
const siblings = [...items, item]
return { items: siblings, changed: true, siblings }
}
let changed = false
let siblings: T[] = []
const nextItems = items.map((current) => {
if (current.id === parentId) {
siblings = [...((current.children as T[] | undefined) ?? []), item]
changed = true
return { ...current, children: siblings } as T
}
if (current.children?.length) {
const appended = appendDirectoryToParent(current.children as T[], parentId, item)
if (appended.changed) {
changed = true
siblings = appended.siblings
return { ...current, children: appended.items } as T
}
}
return current
})
return {
items: changed ? nextItems : items,
changed,
siblings,
}
}
function moveSiblingList<T extends SortableKnowledgeDirectory>(
items: T[],
parentId: number,
activeId: number,
overId: number,
): MoveDirectoryResult<T> {
const oldIndex = items.findIndex((item) => item.id === activeId)
const newIndex = items.findIndex((item) => item.id === overId)
if (oldIndex < 0 || newIndex < 0) {
return { items, changed: false, parentId, orderedIds: [] }
}
const nextItems = arrayMove(items, oldIndex, newIndex)
return {
items: nextItems,
changed: true,
parentId,
orderedIds: nextItems.map((item) => item.id),
}
}

View File

@@ -0,0 +1,282 @@
"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"
import { useI18n } from "@/i18n/provider"
import {
getKnowledgeAnswerStatusLabel,
getKnowledgeChunkProviderLabel,
getKnowledgeRetrieveChannelLabel,
getKnowledgeRetrieveSceneLabel,
} from "@/lib/knowledge-i18n"
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
}
}
type TFunction = (key: string, values?: Record<string, string | number>) => string
function CitationList({ hits, t }: { hits: KnowledgeRetrieveHit[]; t: TFunction }) {
const citations = hits.filter((item) => item.isCitation)
if (citations.length === 0) {
return <div className="text-sm text-muted-foreground">{t("knowledge.noCitations")}</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, t)}</span>
<Badge variant="outline">Chunk #{item.chunkNo}</Badge>
</div>
<div className="mt-1 text-xs text-muted-foreground">
{item.sectionPath || item.title || t("knowledge.unrecordedSection")}
</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 t = useI18n()
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 : t("knowledge.loadRetrieveLogDetailFailed"))
} finally {
setLoading(false)
}
}, [retrieveLogId, t])
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>{t("knowledge.retrieveLogDetail")}</DrawerTitle>
<DrawerDescription>
{detail?.log.question || (loading ? t("knowledge.loading") : t("knowledge.retrieveLogNotFound"))}
</DrawerDescription>
</DrawerHeader>
<ScrollArea className="h-[calc(100vh-6rem)] px-4 pb-6">
{!detail ? (
<div className="py-6 text-sm text-muted-foreground">
{loading ? t("knowledge.loadingDetail") : t("knowledge.emptyDetail")}
</div>
) : (
<div className="space-y-6 pb-6">
<section className="space-y-3">
<h3 className="text-sm font-semibold">{t("knowledge.requestInfo")}</h3>
<div className="grid gap-3 rounded-lg border p-4 md:grid-cols-2">
<div>
<div className="text-xs text-muted-foreground">{t("knowledge.title")}</div>
<div className="mt-1 text-sm">{detail.log.knowledgeBaseName || `#${detail.log.knowledgeBaseId}`}</div>
</div>
<div>
<div className="text-xs text-muted-foreground">{t("knowledge.createdAt")}</div>
<div className="mt-1 text-sm">{formatDateTime(detail.log.createdAt)}</div>
</div>
<div>
<div className="text-xs text-muted-foreground">{t("knowledge.channelScene")}</div>
<div className="mt-1 text-sm">
{getKnowledgeRetrieveChannelLabel(detail.log.channel, detail.log.channelName, t)} /{" "}
{getKnowledgeRetrieveSceneLabel(detail.log.scene, detail.log.sceneName, t)}
</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">{t("knowledge.originalQuestion")}</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">{t("knowledge.rewriteQuestion")}</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">{t("knowledge.answerContent")}</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">{t("knowledge.retrieveStrategy")}</h3>
<div className="grid gap-3 rounded-lg border p-4 md:grid-cols-3">
<Metric
label="Chunk Provider"
value={detail.log.chunkProvider ? getKnowledgeChunkProviderLabel(detail.log.chunkProvider, t) : "-"}
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 ? t("knowledge.rerankEnabled") : t("knowledge.rerankDisabled")} />
<Metric label="Rerank Limit" value={detail.log.rerankLimit} />
</div>
</section>
<section className="space-y-3">
<h3 className="text-sm font-semibold">{t("knowledge.resultSummary")}</h3>
<div className="grid gap-3 rounded-lg border p-4 md:grid-cols-4">
<Metric
label={t("knowledge.answerStatus")}
value={getKnowledgeAnswerStatusLabel(detail.log.answerStatus, detail.log.answerStatusName, t)}
/>
<Metric label={t("knowledge.hitCount")} value={detail.log.hitCount} />
<Metric label={t("knowledge.citations")} value={detail.log.citationCount} />
<Metric label={t("knowledge.contextChunks")} value={detail.log.usedChunkCount} />
<Metric label="Top Score" value={detail.log.topScore.toFixed(4)} mono />
<Metric label={t("knowledge.retrieveMs")} value={`${detail.log.retrieveMs} ms`} />
<Metric label={t("knowledge.generateMs")} value={`${detail.log.generateMs} ms`} />
<Metric label={t("knowledge.totalMs")} value={`${detail.log.latencyMs} ms`} />
<Metric label="Prompt Tokens" value={detail.log.promptTokens} />
<Metric label="Completion Tokens" value={detail.log.completionTokens} />
<Metric label={t("knowledge.model")} value={detail.log.modelName || "-"} mono />
<Metric label={t("knowledge.sessionId")} value={detail.log.sessionId || "-"} mono />
</div>
</section>
<section className="space-y-3">
<h3 className="text-sm font-semibold">{t("knowledge.citationSources")}</h3>
<CitationList hits={detail.hits} t={t} />
</section>
<section className="space-y-3">
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold">{t("knowledge.hitDetails")}</h3>
<div className="text-xs text-muted-foreground">{t("knowledge.itemsCount", { count: 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, t)}</span>
<Badge variant={item.usedInAnswer ? "default" : "secondary"}>
{item.usedInAnswer ? t("knowledge.usedInContext") : t("knowledge.notUsedInContext")}
</Badge>
{item.isCitation ? <Badge>{t("knowledge.citations")}</Badge> : null}
</div>
<div className="mt-2 flex flex-wrap gap-x-4 gap-y-1 text-xs text-muted-foreground">
<span>{t("knowledge.section", { section: 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, t: TFunction) {
if (item.faqQuestion) {
return item.faqQuestion
}
if (item.documentTitle) {
return item.documentTitle
}
if (item.faqId > 0) {
return `FAQ #${item.faqId}`
}
return `${t("knowledge.document")} #${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>
)
}

View File

@@ -0,0 +1,301 @@
"use client"
import { useMemo, useState } from "react"
import { SearchIcon } from "lucide-react"
import {
fetchKnowledgeRetrieveLogs,
type KnowledgeRetrieveLog,
} from "@/lib/api/admin"
import {
KnowledgeAnswerStatus,
KnowledgeRetrieveChannel,
KnowledgeRetrieveScene,
} from "@/lib/generated/enums"
import {
getKnowledgeAnswerStatusLabel,
getKnowledgeChunkProviderLabel,
getKnowledgeRetrieveChannelLabel,
getKnowledgeRetrieveSceneLabel,
} from "@/lib/knowledge-i18n"
import { formatDateTime } from "@/lib/utils"
import { useI18n } from "@/i18n/provider"
import { DashboardListPage } from "@/components/dashboard/list"
import { Badge } from "@/components/ui/badge"
import { RetrieveLogDetailDrawer } from "./retrieve-log-detail"
type RetrieveLogListProps = {
knowledgeBaseId: number | null
}
type TFunction = (key: string, values?: Record<string, string | number>) => string
function getChannelOptions(t: TFunction) {
return [
{ value: "all", label: t("knowledge.allChannels") },
{ value: KnowledgeRetrieveChannel.IM, label: t("knowledge.channelIM") },
{ value: KnowledgeRetrieveChannel.AgentAssist, label: t("knowledge.channelAgentAssist") },
{ value: KnowledgeRetrieveChannel.API, label: t("knowledge.channelAPI") },
{ value: KnowledgeRetrieveChannel.Debug, label: t("knowledge.channelDebug") },
]
}
function getSceneOptions(t: TFunction) {
return [
{ value: "all", label: t("knowledge.allScenes") },
{ value: KnowledgeRetrieveScene.FirstResponse, label: t("knowledge.sceneFirstResponse") },
{ value: KnowledgeRetrieveScene.Assist, label: t("knowledge.sceneAssist") },
{ value: KnowledgeRetrieveScene.QA, label: t("knowledge.sceneQA") },
]
}
function getAnswerStatusOptions(t: TFunction) {
return [
{ value: "all", label: t("knowledge.allAnswerStatus") },
{ value: String(KnowledgeAnswerStatus.Normal), label: t("knowledge.answerNormal") },
{ value: String(KnowledgeAnswerStatus.NoAnswer), label: t("knowledge.answerNoAnswer") },
{ value: String(KnowledgeAnswerStatus.Fallback), label: t("knowledge.answerFallback") },
{ value: String(KnowledgeAnswerStatus.Blocked), label: t("knowledge.answerBlocked") },
]
}
function getProviderOptions(t: TFunction) {
return [
{ value: "all", label: t("knowledge.allChunkProviders") },
{ value: "fixed", label: t("knowledge.chunkFixed") },
{ value: "structured", label: t("knowledge.chunkStructured") },
{ value: "faq", label: t("knowledge.chunkFAQ") },
{ value: "semantic", label: t("knowledge.chunkSemantic") },
]
}
function getRerankOptions(t: TFunction) {
return [
{ value: "all", label: t("knowledge.allRerank") },
{ value: "1", label: t("knowledge.rerankEnabled") },
{ value: "0", label: t("knowledge.rerankDisabled") },
]
}
function channelLabel(value: string, fallback: string, t: TFunction) {
return getKnowledgeRetrieveChannelLabel(value, fallback, t)
}
function sceneLabel(value: string, fallback: string, t: TFunction) {
return getKnowledgeRetrieveSceneLabel(value, fallback, t)
}
function answerStatusLabel(value: number, fallback: string, t: TFunction) {
return getKnowledgeAnswerStatusLabel(value, fallback, t)
}
function providerLabel(value: string, t: TFunction) {
return getKnowledgeChunkProviderLabel(value, t)
}
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 t = useI18n()
const [detailOpen, setDetailOpen] = useState(false)
const [selectedLogId, setSelectedLogId] = useState<number | null>(null)
const [selectedKnowledgeBaseId, setSelectedKnowledgeBaseId] = useState<number | null>(null)
const channelOptions = useMemo(() => getChannelOptions(t), [t])
const sceneOptions = useMemo(() => getSceneOptions(t), [t])
const answerStatusOptions = useMemo(() => getAnswerStatusOptions(t), [t])
const providerOptions = useMemo(() => getProviderOptions(t), [t])
const rerankOptions = useMemo(() => getRerankOptions(t), [t])
function handleOpenDetail(logId: number) {
setSelectedLogId(logId)
setSelectedKnowledgeBaseId(knowledgeBaseId)
setDetailOpen(true)
}
if (!knowledgeBaseId) {
return (
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
{t("knowledge.selectBaseForLogs")}
</div>
)
}
return (
<>
<div className="flex h-full flex-col gap-4 px-6 py-4">
<DashboardListPage<KnowledgeRetrieveLog>
layout="fragment"
filters={[
{
name: "question",
label: t("knowledge.filterQuestion"),
placeholder: t("knowledge.filterQuestion"),
defaultValue: "",
trim: true,
className: "min-w-0 xl:min-w-[280px]",
inputClassName: "pl-9",
icon: <SearchIcon className="size-4" />,
},
{
name: "channel",
label: t("knowledge.selectChannel"),
type: "select",
defaultValue: "all",
allValue: "all",
options: channelOptions,
placeholder: t("knowledge.selectChannel"),
},
{
name: "scene",
label: t("knowledge.selectScene"),
type: "select",
defaultValue: "all",
allValue: "all",
options: sceneOptions,
placeholder: t("knowledge.selectScene"),
},
{
name: "answerStatus",
label: t("knowledge.answerStatus"),
type: "select",
defaultValue: "all",
allValue: "all",
valueType: "number",
options: answerStatusOptions,
placeholder: t("knowledge.answerStatus"),
},
{
name: "chunkProvider",
label: t("knowledge.chunkStrategy"),
type: "select",
defaultValue: "all",
allValue: "all",
options: providerOptions,
placeholder: t("knowledge.chunkStrategy"),
},
{
name: "rerankEnabled",
label: "Rerank",
type: "select",
defaultValue: "all",
allValue: "all",
valueType: "number",
options: rerankOptions,
placeholder: "Rerank",
},
]}
fetchList={(query) => fetchKnowledgeRetrieveLogs({ knowledgeBaseId, ...query })}
reloadKey={knowledgeBaseId}
getItemId={(item) => item.id}
getRowClassName={() => "cursor-pointer"}
onRowClick={(item) => handleOpenDetail(item.id)}
columns={[
{
key: "time",
label: t("knowledge.time"),
className: "w-42 text-xs text-muted-foreground",
render: (item) => formatDateTime(item.createdAt),
},
{
key: "question",
label: t("knowledge.question"),
render: (item) => (
<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>{channelLabel(item.channel, item.channelName, t)}</span>
<span>{sceneLabel(item.scene, item.sceneName, t)}</span>
{item.knowledgeBaseName ? <span>{item.knowledgeBaseName}</span> : null}
</div>
</div>
),
},
{
key: "answerStatus",
label: t("knowledge.answerStatus"),
className: "w-28",
render: (item) => (
<Badge variant={getAnswerStatusVariant(item.answerStatus)}>
{answerStatusLabel(item.answerStatus, item.answerStatusName, t)}
</Badge>
),
},
{
key: "hitCount",
label: t("knowledge.hitCount"),
className: "w-24 text-right",
render: (item) => item.hitCount,
},
{
key: "topScore",
label: "TopScore",
className: "w-24 text-right font-mono text-xs",
render: (item) => item.topScore.toFixed(4),
},
{
key: "provider",
label: "Provider",
className: "w-28",
render: (item) =>
item.chunkProvider ? providerLabel(item.chunkProvider, t) : "-",
},
{
key: "rerank",
label: "Rerank",
className: "w-24",
render: (item) =>
item.rerankEnabled
? `${t("knowledge.yes")} (${item.rerankLimit})`
: t("knowledge.no"),
},
{
key: "citations",
label: t("knowledge.citations"),
className: "w-24 text-right",
render: (item) => item.citationCount,
},
{
key: "duration",
label: t("knowledge.duration"),
className: "w-28 text-right",
render: (item) => `${item.latencyMs} ms`,
},
]}
labels={{
query: t("knowledge.filter"),
loading: t("knowledge.loadingRetrieveLogs"),
empty: t("knowledge.emptyRetrieveLogs"),
loadFailed: t("knowledge.loadRetrieveLogsFailed"),
}}
/>
</div>
<RetrieveLogDetailDrawer
open={detailOpen && selectedKnowledgeBaseId === knowledgeBaseId}
retrieveLogId={selectedLogId}
onOpenChange={(open) => {
setDetailOpen(open)
if (!open) {
setSelectedLogId(null)
setSelectedKnowledgeBaseId(null)
}
}}
/>
</>
)
}

View File

@@ -0,0 +1,26 @@
"use client";
import type { ComponentProps } from "react";
import { Checkbox } from "@/components/ui/checkbox";
import { cn } from "@/lib/utils";
type SelectionCheckboxProps = ComponentProps<typeof Checkbox> & {
wrapperClassName?: string;
};
export function SelectionCheckbox({
className,
wrapperClassName,
...props
}: SelectionCheckboxProps) {
return (
<span
className={cn("inline-flex shrink-0", wrapperClassName)}
onPointerDown={(event) => event.stopPropagation()}
onClick={(event) => event.stopPropagation()}
>
<Checkbox className={className} {...props} />
</span>
);
}