Files
ai-agent/web/app/dashboard/knowledge/_components/faq-import-dialog.tsx
T

415 lines
12 KiB
TypeScript
Raw Normal View History

2026-04-09 10:01:23 +08:00
"use client";
import { DownloadIcon, FileUpIcon, InfoIcon } from "lucide-react";
import { useMemo, useRef, useState } from "react";
import { toast } from "sonner";
import { ProjectDialog } from "@/components/project-dialog";
import { Button } from "@/components/ui/button";
import {
Field,
FieldContent,
FieldDescription,
FieldGroup,
FieldLabel,
} from "@/components/ui/field";
import { Input } from "@/components/ui/input";
import { ScrollArea } from "@/components/ui/scroll-area";
import { createKnowledgeFAQ, type CreateKnowledgeFAQPayload } from "@/lib/api/admin";
2026-05-25 12:06:15 +08:00
import { useI18n } from "@/i18n/provider";
2026-04-09 10:01:23 +08:00
type FAQImportDialogProps = {
open: boolean;
knowledgeBaseId: number | null;
importing: boolean;
onOpenChange: (open: boolean) => void;
onImportingChange: (importing: boolean) => void;
onImported: () => Promise<void>;
};
type ParsedFAQRow = {
rowNo: number;
question: string;
answer: string;
similarQuestions: string[];
remark: string;
};
type ParseResult = {
rows: ParsedFAQRow[];
warnings: string[];
};
2026-05-25 12:06:15 +08:00
type TFunction = (key: string, values?: Record<string, string | number>) => string;
2026-04-09 10:01:23 +08:00
const acceptedHeaderMap: Record<string, keyof Omit<ParsedFAQRow, "rowNo">> = {
question: "question",
answer: "answer",
similarquestions: "similarQuestions",
"similarQuestions": "similarQuestions",
remark: "remark",
2026-05-25 12:06:15 +08:00
"\u6807\u51c6\u95ee\u9898": "question",
"\u95ee\u9898": "question",
"\u7b54\u6848": "answer",
"\u76f8\u4f3c\u95ee": "similarQuestions",
"\u76f8\u4f3c\u95ee\u9898": "similarQuestions",
"\u5907\u6ce8": "remark",
2026-04-09 10:01:23 +08:00
};
function normalizeHeader(value: string) {
return value.trim().replace(/^\uFEFF/, "").toLowerCase();
}
function parseDelimitedText(input: string): string[][] {
const text = input.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
const rows: string[][] = [];
let row: string[] = [];
let cell = "";
let inQuotes = false;
let delimiter = ",";
function pushCell() {
row.push(cell.trim());
cell = "";
}
function pushRow() {
if (row.length === 1 && row[0] === "" && rows.length === 0) {
row = [];
return;
}
if (row.some((item) => item !== "")) {
rows.push(row);
}
row = [];
}
for (let i = 0; i < text.length; i += 1) {
const char = text[i];
const next = text[i + 1];
if (!inQuotes && rows.length === 0 && row.length === 0 && cell.length > 0 && char === "\t") {
delimiter = "\t";
}
if (char === '"') {
if (inQuotes && next === '"') {
cell += '"';
i += 1;
continue;
}
inQuotes = !inQuotes;
continue;
}
if (!inQuotes && char === delimiter) {
pushCell();
continue;
}
if (!inQuotes && char === "\n") {
pushCell();
pushRow();
continue;
}
cell += char;
}
if (cell.length > 0 || row.length > 0) {
pushCell();
pushRow();
}
return rows;
}
function parseSimilarQuestions(value: string) {
return value
.split(/\r?\n|\|/g)
.map((item) => item.trim())
.filter(Boolean);
}
2026-05-25 12:06:15 +08:00
function parseFAQFileContent(input: string, t: TFunction): ParseResult {
2026-04-09 10:01:23 +08:00
const table = parseDelimitedText(input);
if (table.length === 0) {
2026-05-25 12:06:15 +08:00
throw new Error(t("knowledge.fileEmpty"));
2026-04-09 10:01:23 +08:00
}
const headerRow = table[0];
const headerMap = new Map<keyof Omit<ParsedFAQRow, "rowNo">, number>();
for (let index = 0; index < headerRow.length; index += 1) {
const header = acceptedHeaderMap[normalizeHeader(headerRow[index])];
if (header && !headerMap.has(header)) {
headerMap.set(header, index);
}
}
if (!headerMap.has("question") || !headerMap.has("answer")) {
2026-05-25 12:06:15 +08:00
throw new Error(t("knowledge.missingFAQColumns"));
2026-04-09 10:01:23 +08:00
}
const rows: ParsedFAQRow[] = [];
const warnings: string[] = [];
for (let index = 1; index < table.length; index += 1) {
const current = table[index];
const rowNo = index + 1;
const question = current[headerMap.get("question") ?? -1]?.trim() ?? "";
const answer = current[headerMap.get("answer") ?? -1]?.trim() ?? "";
const similarQuestionsRaw = current[headerMap.get("similarQuestions") ?? -1]?.trim() ?? "";
const remark = current[headerMap.get("remark") ?? -1]?.trim() ?? "";
if (!question && !answer && !similarQuestionsRaw && !remark) {
continue;
}
if (!question || !answer) {
2026-05-25 12:06:15 +08:00
warnings.push(t("knowledge.skipRowMissingFAQ", { row: rowNo }));
2026-04-09 10:01:23 +08:00
continue;
}
rows.push({
rowNo,
question,
answer,
similarQuestions: parseSimilarQuestions(similarQuestionsRaw),
remark,
});
}
return { rows, warnings };
}
function downloadTemplate() {
2026-05-25 12:06:15 +08:00
const templateContent = [
"question,answer,similarQuestions,remark",
'"How do I reset my password?","Open profile settings and choose Reset Password.","forgot password|where is reset password","Account FAQ"',
'"Which channels are supported?","Web chat and WeCom customer service channels are currently supported.","available channels|supported channels","Channel guide"',
].join("\n");
2026-04-09 10:01:23 +08:00
const blob = new Blob([templateContent], { type: "text/csv;charset=utf-8;" });
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = "knowledge-faq-import-template.csv";
link.click();
URL.revokeObjectURL(url);
}
function buildPayload(row: ParsedFAQRow, knowledgeBaseId: number): CreateKnowledgeFAQPayload {
return {
knowledgeBaseId,
question: row.question,
answer: row.answer,
similarQuestions: row.similarQuestions,
remark: row.remark,
};
}
export function FAQImportDialog({
open,
knowledgeBaseId,
importing,
onOpenChange,
onImportingChange,
onImported,
}: FAQImportDialogProps) {
2026-05-25 12:06:15 +08:00
const t = useI18n();
2026-04-09 10:01:23 +08:00
const fileInputRef = useRef<HTMLInputElement | null>(null);
const [fileName, setFileName] = useState("");
const [rows, setRows] = useState<ParsedFAQRow[]>([]);
const [warnings, setWarnings] = useState<string[]>([]);
const previewRows = useMemo(() => rows.slice(0, 5), [rows]);
function resetState() {
setFileName("");
setRows([]);
setWarnings([]);
if (fileInputRef.current) {
fileInputRef.current.value = "";
}
}
async function handleFileChange(event: React.ChangeEvent<HTMLInputElement>) {
const file = event.target.files?.[0];
if (!file) {
return;
}
try {
const content = await file.text();
2026-05-25 12:06:15 +08:00
const parsed = parseFAQFileContent(content, t);
2026-04-09 10:01:23 +08:00
setFileName(file.name);
setRows(parsed.rows);
setWarnings(parsed.warnings);
if (parsed.rows.length === 0) {
2026-05-25 12:06:15 +08:00
toast.error(t("knowledge.importNoRows"));
2026-04-09 10:01:23 +08:00
} else {
2026-05-25 12:06:15 +08:00
toast.success(t("knowledge.parsedFAQRows", { count: parsed.rows.length }));
2026-04-09 10:01:23 +08:00
}
} catch (error) {
resetState();
2026-05-25 12:06:15 +08:00
toast.error(error instanceof Error ? error.message : t("knowledge.parseImportFailed"));
2026-04-09 10:01:23 +08:00
}
}
async function handleImport() {
if (!knowledgeBaseId || rows.length === 0 || importing) {
return;
}
onImportingChange(true);
let successCount = 0;
const failedRows: string[] = [];
try {
for (const row of rows) {
try {
await createKnowledgeFAQ(buildPayload(row, knowledgeBaseId));
successCount += 1;
} catch (error) {
failedRows.push(
2026-05-25 12:06:15 +08:00
t("knowledge.importRowFailed", {
row: row.rowNo,
message: error instanceof Error ? error.message : t("knowledge.importFailed"),
})
2026-04-09 10:01:23 +08:00
);
}
}
await onImported();
if (successCount > 0) {
2026-05-25 12:06:15 +08:00
toast.success(t("knowledge.importSuccess", { count: successCount }));
2026-04-09 10:01:23 +08:00
}
if (failedRows.length > 0) {
2026-05-25 12:06:15 +08:00
toast.error(t("knowledge.importSomeFailed", { count: failedRows.length }));
2026-04-09 10:01:23 +08:00
setWarnings((current) => [...current, ...failedRows]);
return;
}
resetState();
onOpenChange(false);
} finally {
onImportingChange(false);
}
}
return (
<ProjectDialog
open={open}
onOpenChange={(nextOpen) => {
if (!nextOpen && !importing) {
resetState();
}
onOpenChange(nextOpen);
}}
2026-05-25 12:06:15 +08:00
title={t("knowledge.importFAQTitle")}
description={t("knowledge.importFAQDescription")}
2026-04-09 10:01:23 +08:00
size="lg"
footer={
<>
<Button type="button" variant="outline" onClick={() => downloadTemplate()}>
<DownloadIcon className="size-4" />
2026-05-25 12:06:15 +08:00
{t("knowledge.downloadTemplate")}
2026-04-09 10:01:23 +08:00
</Button>
<Button type="button" variant="outline" onClick={() => onOpenChange(false)} disabled={importing}>
2026-05-25 12:06:15 +08:00
{t("knowledge.cancel")}
2026-04-09 10:01:23 +08:00
</Button>
<Button type="button" onClick={() => void handleImport()} disabled={importing || rows.length === 0}>
2026-05-25 12:06:15 +08:00
{importing
? t("knowledge.importing")
: rows.length > 0
? t("knowledge.startImportWithCount", { count: rows.length })
: t("knowledge.startImport")}
2026-04-09 10:01:23 +08:00
</Button>
</>
}
>
<FieldGroup>
<Field>
2026-05-25 12:06:15 +08:00
<FieldLabel htmlFor="faq-import-file">{t("knowledge.importFile")}</FieldLabel>
2026-04-09 10:01:23 +08:00
<FieldContent>
<div className="flex items-center gap-2">
<Input
id="faq-import-file"
ref={fileInputRef}
type="file"
accept=".csv,text/csv,.txt"
onChange={(event) => void handleFileChange(event)}
/>
<Button
type="button"
variant="outline"
onClick={() => fileInputRef.current?.click()}
>
<FileUpIcon className="size-4" />
2026-05-25 12:06:15 +08:00
{t("knowledge.chooseFile")}
2026-04-09 10:01:23 +08:00
</Button>
</div>
<FieldDescription>
2026-05-25 12:06:15 +08:00
{t("knowledge.importFileDescription")}
2026-04-09 10:01:23 +08:00
</FieldDescription>
</FieldContent>
</Field>
{fileName ? (
<div className="rounded-md border bg-muted/20 px-3 py-2 text-sm">
2026-05-25 12:06:15 +08:00
{t("knowledge.currentFile", { name: fileName })}
2026-04-09 10:01:23 +08:00
</div>
) : null}
{warnings.length > 0 ? (
<div className="rounded-md border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-900">
<div className="mb-2 flex items-center gap-2 font-medium">
<InfoIcon className="size-4" />
2026-05-25 12:06:15 +08:00
{t("knowledge.importHint")}
2026-04-09 10:01:23 +08:00
</div>
<ul className="space-y-1">
{warnings.map((item, index) => (
<li key={`${item}-${index}`}>{item}</li>
))}
</ul>
</div>
) : null}
<div className="rounded-md border">
<div className="border-b px-4 py-3 text-sm font-medium">
2026-05-25 12:06:15 +08:00
{t("knowledge.importPreview")}
2026-04-09 10:01:23 +08:00
</div>
{previewRows.length > 0 ? (
<ScrollArea className="max-h-80">
<div className="divide-y">
{previewRows.map((row) => (
<div key={row.rowNo} className="space-y-2 px-4 py-3 text-sm">
<div>
2026-05-25 12:06:15 +08:00
<span className="text-muted-foreground">{t("knowledge.rowNumber", { row: row.rowNo })}</span>
2026-04-09 10:01:23 +08:00
</div>
<div>
<div className="font-medium">{row.question}</div>
<div className="mt-1 whitespace-pre-wrap text-muted-foreground">
{row.answer}
</div>
</div>
<div className="text-muted-foreground">
2026-05-25 12:06:15 +08:00
{t("knowledge.similarQuestionShort", {
value: row.similarQuestions.length > 0 ? row.similarQuestions.join(" / ") : t("knowledge.none"),
})}
2026-04-09 10:01:23 +08:00
</div>
2026-05-25 12:06:15 +08:00
<div className="text-muted-foreground">{t("knowledge.remark")}{row.remark || t("knowledge.none")}</div>
2026-04-09 10:01:23 +08:00
</div>
))}
</div>
</ScrollArea>
) : (
<div className="px-4 py-12 text-center text-sm text-muted-foreground">
2026-05-25 12:06:15 +08:00
{t("knowledge.previewAfterUpload")}
2026-04-09 10:01:23 +08:00
</div>
)}
</div>
</FieldGroup>
</ProjectDialog>
);
}