chore: import clean Agent Desk snapshot (6/7)
This commit is contained in:
@@ -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}`
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
289
web/app/(dashboard)/dashboard/knowledge/_components/faq-edit.tsx
Normal file
289
web/app/(dashboard)/dashboard/knowledge/_components/faq-edit.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
653
web/app/(dashboard)/dashboard/knowledge/_components/faq-list.tsx
Normal file
653
web/app/(dashboard)/dashboard/knowledge/_components/faq-list.tsx
Normal 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)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user