page.tsx 43 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213
  1. "use client";
  2. import { useCallback, useEffect, useState } from "react";
  3. import { useRouter } from "next/navigation";
  4. import { useAuth } from "@/features/agent/hooks/useAuth";
  5. import { ResponsiveLayout } from "@/components/layout";
  6. import {
  7. fetchKnowledgeBases,
  8. createKnowledgeBase,
  9. updateKnowledgeBase,
  10. updateKnowledgeBaseRAGEnabled,
  11. deleteKnowledgeBase,
  12. type KnowledgeBase,
  13. type CreateKnowledgeBaseRequest,
  14. type UpdateKnowledgeBaseRequest,
  15. } from "@/features/agent/services/knowledgeBaseApi";
  16. import {
  17. fetchDocuments,
  18. createDocument,
  19. updateDocument,
  20. deleteDocument,
  21. publishDocument,
  22. unpublishDocument,
  23. type Document,
  24. type CreateDocumentRequest,
  25. type UpdateDocumentRequest,
  26. type DocumentListResult,
  27. } from "@/features/agent/services/documentApi";
  28. import {
  29. importDocuments,
  30. importFromUrls,
  31. type ImportResult,
  32. } from "@/features/agent/services/importApi";
  33. import { Button } from "@/components/ui/button";
  34. import { Input } from "@/components/ui/input";
  35. import {
  36. Dialog,
  37. DialogContent,
  38. DialogHeader,
  39. DialogTitle,
  40. DialogDescription,
  41. } from "@/components/ui/dialog";
  42. import { Card } from "@/components/ui/card";
  43. import { Label } from "@/components/ui/label";
  44. import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
  45. import { Switch } from "@/components/ui/switch";
  46. import {
  47. Plus,
  48. Edit,
  49. Trash2,
  50. Search,
  51. FileText,
  52. Upload,
  53. Link as LinkIcon,
  54. BookOpen,
  55. CheckCircle2,
  56. XCircle,
  57. Loader2,
  58. ChevronLeft,
  59. ChevronRight,
  60. } from "lucide-react";
  61. import { Textarea } from "@/components/ui/textarea";
  62. import { toast } from "@/hooks/useToast";
  63. export default function KnowledgePage(props: any = {}) {
  64. const { embedded = false } = props;
  65. const router = useRouter();
  66. const { agent } = useAuth();
  67. // 知识库状态
  68. const [knowledgeBases, setKnowledgeBases] = useState<KnowledgeBase[]>([]);
  69. const [selectedKnowledgeBase, setSelectedKnowledgeBase] = useState<KnowledgeBase | null>(null);
  70. const [loadingKBs, setLoadingKBs] = useState(true);
  71. // 文档状态
  72. const [documents, setDocuments] = useState<Document[]>([]);
  73. const [documentResult, setDocumentResult] = useState<DocumentListResult | null>(null);
  74. const [loadingDocs, setLoadingDocs] = useState(false);
  75. const [searchKeyword, setSearchKeyword] = useState("");
  76. const [statusFilter, setStatusFilter] = useState<string>("all");
  77. const [currentPage, setCurrentPage] = useState(1);
  78. const pageSize = 20;
  79. // 对话框状态
  80. const [createKBDialogOpen, setCreateKBDialogOpen] = useState(false);
  81. const [editKBDialogOpen, setEditKBDialogOpen] = useState(false);
  82. const [deleteKBDialogOpen, setDeleteKBDialogOpen] = useState(false);
  83. const [createDocDialogOpen, setCreateDocDialogOpen] = useState(false);
  84. const [editDocDialogOpen, setEditDocDialogOpen] = useState(false);
  85. const [deleteDocDialogOpen, setDeleteDocDialogOpen] = useState(false);
  86. const [importDialogOpen, setImportDialogOpen] = useState(false);
  87. const [importTab, setImportTab] = useState<"file" | "url">("file");
  88. const [selectedDocument, setSelectedDocument] = useState<Document | null>(null);
  89. // 表单状态
  90. const [submitting, setSubmitting] = useState(false);
  91. const [createKBForm, setCreateKBForm] = useState<CreateKnowledgeBaseRequest>({
  92. name: "",
  93. description: "",
  94. });
  95. const [editKBForm, setEditKBForm] = useState<UpdateKnowledgeBaseRequest>({});
  96. const [createDocForm, setCreateDocForm] = useState<CreateDocumentRequest>({
  97. knowledge_base_id: 0,
  98. title: "",
  99. content: "",
  100. summary: "",
  101. type: "document",
  102. status: "draft",
  103. });
  104. const [editDocForm, setEditDocForm] = useState<UpdateDocumentRequest>({});
  105. const [importUrls, setImportUrls] = useState<string>("");
  106. const [importFiles, setImportFiles] = useState<File[]>([]);
  107. // 加载知识库列表(不依赖 selectedKnowledgeBase,避免选中后反复触发 effect 导致疯狂刷新)
  108. const loadKnowledgeBases = useCallback(async () => {
  109. setLoadingKBs(true);
  110. try {
  111. const data = await fetchKnowledgeBases();
  112. setKnowledgeBases(data);
  113. } catch (error) {
  114. console.error("加载知识库列表失败:", error);
  115. toast.error((error as Error).message || "加载知识库列表失败");
  116. } finally {
  117. setLoadingKBs(false);
  118. }
  119. }, []);
  120. // 加载文档列表
  121. const loadDocuments = useCallback(async () => {
  122. if (!selectedKnowledgeBase) {
  123. setDocuments([]);
  124. setDocumentResult(null);
  125. return;
  126. }
  127. setLoadingDocs(true);
  128. try {
  129. const status = statusFilter === "all" ? undefined : statusFilter;
  130. const result = await fetchDocuments(
  131. selectedKnowledgeBase.id,
  132. currentPage,
  133. pageSize,
  134. searchKeyword || undefined,
  135. status
  136. );
  137. setDocumentResult(result);
  138. setDocuments(result.documents ?? []);
  139. } catch (error) {
  140. console.error("加载文档列表失败:", error);
  141. toast.error((error as Error).message || "加载文档列表失败");
  142. setDocuments([]);
  143. setDocumentResult(null);
  144. } finally {
  145. setLoadingDocs(false);
  146. }
  147. }, [selectedKnowledgeBase, currentPage, searchKeyword, statusFilter]);
  148. // 初始加载
  149. useEffect(() => {
  150. loadKnowledgeBases();
  151. }, [loadKnowledgeBases]);
  152. // 当选择知识库或搜索条件变化时,重新加载文档
  153. useEffect(() => {
  154. setCurrentPage(1); // 切换知识库或搜索时重置页码
  155. loadDocuments();
  156. }, [loadDocuments]);
  157. // 选择知识库
  158. const handleSelectKnowledgeBase = (kb: KnowledgeBase) => {
  159. setSelectedKnowledgeBase(kb);
  160. setSearchKeyword("");
  161. setStatusFilter("all");
  162. setCurrentPage(1);
  163. };
  164. // 创建知识库
  165. const handleCreateKB = async () => {
  166. if (!createKBForm.name.trim()) {
  167. toast.error("知识库名称不能为空");
  168. return;
  169. }
  170. setSubmitting(true);
  171. try {
  172. await createKnowledgeBase(createKBForm);
  173. setCreateKBDialogOpen(false);
  174. setCreateKBForm({ name: "", description: "" });
  175. await loadKnowledgeBases();
  176. toast.success("创建成功");
  177. } catch (error) {
  178. toast.error((error as Error).message || "创建知识库失败");
  179. } finally {
  180. setSubmitting(false);
  181. }
  182. };
  183. // 打开编辑知识库对话框
  184. const handleOpenEditKB = (kb: KnowledgeBase) => {
  185. setEditKBForm({
  186. name: kb.name,
  187. description: kb.description,
  188. });
  189. setSelectedKnowledgeBase(kb);
  190. setEditKBDialogOpen(true);
  191. };
  192. // 更新知识库
  193. const handleUpdateKB = async () => {
  194. if (!selectedKnowledgeBase) return;
  195. setSubmitting(true);
  196. try {
  197. await updateKnowledgeBase(selectedKnowledgeBase.id, editKBForm);
  198. setEditKBDialogOpen(false);
  199. await loadKnowledgeBases();
  200. toast.success("更新成功");
  201. } catch (error) {
  202. toast.error((error as Error).message || "更新知识库失败");
  203. } finally {
  204. setSubmitting(false);
  205. }
  206. };
  207. // 打开删除知识库对话框
  208. const handleOpenDeleteKB = (kb: KnowledgeBase) => {
  209. setSelectedKnowledgeBase(kb);
  210. setDeleteKBDialogOpen(true);
  211. };
  212. // 删除知识库
  213. const handleDeleteKB = async () => {
  214. if (!selectedKnowledgeBase) return;
  215. setSubmitting(true);
  216. try {
  217. await deleteKnowledgeBase(selectedKnowledgeBase.id);
  218. setDeleteKBDialogOpen(false);
  219. setSelectedKnowledgeBase(null);
  220. await loadKnowledgeBases();
  221. toast.success("删除成功");
  222. } catch (error) {
  223. toast.error((error as Error).message || "删除知识库失败");
  224. } finally {
  225. setSubmitting(false);
  226. }
  227. };
  228. // 打开创建文档对话框
  229. const handleOpenCreateDoc = () => {
  230. if (!selectedKnowledgeBase) {
  231. toast.error("请先选择知识库");
  232. return;
  233. }
  234. setCreateDocForm({
  235. knowledge_base_id: selectedKnowledgeBase.id,
  236. title: "",
  237. content: "",
  238. summary: "",
  239. type: "document",
  240. status: "draft",
  241. });
  242. setCreateDocDialogOpen(true);
  243. };
  244. // 创建文档
  245. const handleCreateDoc = async () => {
  246. if (!createDocForm.title.trim() || !createDocForm.content.trim()) {
  247. toast.error("标题和内容不能为空");
  248. return;
  249. }
  250. setSubmitting(true);
  251. try {
  252. await createDocument(createDocForm);
  253. setCreateDocDialogOpen(false);
  254. setCreateDocForm({
  255. knowledge_base_id: selectedKnowledgeBase?.id || 0,
  256. title: "",
  257. content: "",
  258. summary: "",
  259. type: "document",
  260. status: "draft",
  261. });
  262. await loadDocuments();
  263. toast.success("创建成功");
  264. } catch (error) {
  265. toast.error((error as Error).message || "创建文档失败");
  266. } finally {
  267. setSubmitting(false);
  268. }
  269. };
  270. // 打开编辑文档对话框
  271. const handleOpenEditDoc = (doc: Document) => {
  272. setSelectedDocument(doc);
  273. setEditDocForm({
  274. title: doc.title,
  275. content: doc.content,
  276. summary: doc.summary,
  277. type: doc.type,
  278. status: doc.status,
  279. });
  280. setEditDocDialogOpen(true);
  281. };
  282. // 更新文档
  283. const handleUpdateDoc = async (docId: number) => {
  284. setSubmitting(true);
  285. try {
  286. await updateDocument(docId, editDocForm);
  287. setEditDocDialogOpen(false);
  288. await loadDocuments();
  289. toast.success("更新成功");
  290. } catch (error) {
  291. toast.error((error as Error).message || "更新文档失败");
  292. } finally {
  293. setSubmitting(false);
  294. }
  295. };
  296. // 打开删除文档对话框
  297. const handleOpenDeleteDoc = (doc: Document) => {
  298. setSelectedDocument(doc);
  299. setDeleteDocDialogOpen(true);
  300. };
  301. // 删除文档
  302. const handleDeleteDoc = async (docId: number) => {
  303. setSubmitting(true);
  304. try {
  305. await deleteDocument(docId);
  306. setDeleteDocDialogOpen(false);
  307. await loadDocuments();
  308. toast.success("删除成功");
  309. } catch (error) {
  310. toast.error((error as Error).message || "删除文档失败");
  311. } finally {
  312. setSubmitting(false);
  313. }
  314. };
  315. // 发布文档
  316. const handlePublishDoc = async (docId: number) => {
  317. try {
  318. await publishDocument(docId);
  319. await loadDocuments();
  320. toast.success("发布成功");
  321. } catch (error) {
  322. toast.error((error as Error).message || "发布文档失败");
  323. }
  324. };
  325. // 取消发布文档
  326. const handleUnpublishDoc = async (docId: number) => {
  327. try {
  328. await unpublishDocument(docId);
  329. await loadDocuments();
  330. toast.success("取消发布成功");
  331. } catch (error) {
  332. toast.error((error as Error).message || "取消发布文档失败");
  333. }
  334. };
  335. // 导入文件
  336. const handleImportFiles = async () => {
  337. if (!selectedKnowledgeBase) {
  338. toast.error("请先选择知识库");
  339. return;
  340. }
  341. if (importFiles.length === 0) {
  342. toast.error("请选择要导入的文件");
  343. return;
  344. }
  345. setSubmitting(true);
  346. try {
  347. const result: ImportResult = await importDocuments(selectedKnowledgeBase.id, importFiles);
  348. const errMsg = result.errors?.length ? result.errors[0] : "";
  349. if (result.failed_count > 0 && result.success_count === 0) {
  350. toast.error(errMsg || `导入失败:${result.failed_count} 个文件未成功`);
  351. } else if (result.failed_count > 0) {
  352. toast.success(`导入完成:成功 ${result.success_count},失败 ${result.failed_count}${errMsg ? `(${errMsg})` : ""}`);
  353. } else {
  354. toast.success(`导入完成:成功 ${result.success_count} 个文件`);
  355. }
  356. setImportDialogOpen(false);
  357. setImportFiles([]);
  358. try {
  359. await loadDocuments();
  360. await loadKnowledgeBases();
  361. } catch {
  362. toast.error("导入成功,但刷新列表失败,请手动刷新页面");
  363. }
  364. } catch (error) {
  365. toast.error((error as Error).message || "导入文档失败");
  366. } finally {
  367. setSubmitting(false);
  368. }
  369. };
  370. // 导入 URL
  371. const handleImportUrls = async () => {
  372. if (!selectedKnowledgeBase) {
  373. toast.error("请先选择知识库");
  374. return;
  375. }
  376. const urls = importUrls
  377. .split("\n")
  378. .map((url) => url.trim())
  379. .filter((url) => url.length > 0);
  380. if (urls.length === 0) {
  381. toast.error("请输入至少一个 URL");
  382. return;
  383. }
  384. setSubmitting(true);
  385. try {
  386. const result: ImportResult = await importFromUrls({
  387. knowledge_base_id: selectedKnowledgeBase.id,
  388. urls,
  389. });
  390. const errMsg = result.errors?.length ? result.errors[0] : "";
  391. if (result.failed_count > 0 && result.success_count === 0) {
  392. toast.error(errMsg || `导入失败:${result.failed_count} 个 URL 未成功`);
  393. } else if (result.failed_count > 0) {
  394. toast.success(`导入完成:成功 ${result.success_count},失败 ${result.failed_count}${errMsg ? `(${errMsg})` : ""}`);
  395. } else {
  396. toast.success(`导入完成:成功 ${result.success_count} 个 URL`);
  397. }
  398. setImportDialogOpen(false);
  399. setImportUrls("");
  400. try {
  401. await loadDocuments();
  402. await loadKnowledgeBases();
  403. } catch {
  404. toast.error("导入成功,但刷新列表失败,请手动刷新页面");
  405. }
  406. } catch (error) {
  407. toast.error((error as Error).message || "导入 URL 失败");
  408. } finally {
  409. setSubmitting(false);
  410. }
  411. };
  412. // 格式化时间
  413. const formatTime = (dateStr: string) => {
  414. const date = new Date(dateStr);
  415. return date.toLocaleString("zh-CN", {
  416. year: "numeric",
  417. month: "2-digit",
  418. day: "2-digit",
  419. hour: "2-digit",
  420. minute: "2-digit",
  421. });
  422. };
  423. // 获取状态标签
  424. const getStatusBadge = (status: string) => {
  425. switch (status) {
  426. case "published":
  427. return (
  428. <span className="inline-flex items-center px-2 py-1 rounded-full text-xs bg-green-100 text-green-800">
  429. <CheckCircle2 className="w-3 h-3 mr-1" />
  430. 已发布
  431. </span>
  432. );
  433. case "draft":
  434. return (
  435. <span className="inline-flex items-center px-2 py-1 rounded-full text-xs bg-gray-100 text-gray-800">
  436. 草稿
  437. </span>
  438. );
  439. default:
  440. return (
  441. <span className="inline-flex items-center px-2 py-1 rounded-full text-xs bg-gray-100 text-gray-800">
  442. {status}
  443. </span>
  444. );
  445. }
  446. };
  447. // 获取向量化状态标签
  448. const getEmbeddingStatusBadge = (status: string) => {
  449. switch (status) {
  450. case "completed":
  451. return (
  452. <span className="inline-flex items-center px-2 py-1 rounded-full text-xs bg-blue-100 text-blue-800">
  453. <CheckCircle2 className="w-3 h-3 mr-1" />
  454. 已完成
  455. </span>
  456. );
  457. case "processing":
  458. return (
  459. <span className="inline-flex items-center px-2 py-1 rounded-full text-xs bg-yellow-100 text-yellow-800">
  460. <Loader2 className="w-3 h-3 mr-1 animate-spin" />
  461. 处理中
  462. </span>
  463. );
  464. case "failed":
  465. return (
  466. <span className="inline-flex items-center px-2 py-1 rounded-full text-xs bg-red-100 text-red-800">
  467. <XCircle className="w-3 h-3 mr-1" />
  468. 失败
  469. </span>
  470. );
  471. case "pending":
  472. default:
  473. return (
  474. <span className="inline-flex items-center px-2 py-1 rounded-full text-xs bg-gray-100 text-gray-800">
  475. 待处理
  476. </span>
  477. );
  478. }
  479. };
  480. // 构建头部内容
  481. const headerContent = (
  482. <div className="bg-card border-b p-4 shadow-sm">
  483. <div className="flex items-center justify-between mb-4">
  484. <h1 className="text-xl font-bold text-foreground">知识库管理</h1>
  485. {!embedded && (
  486. <Button
  487. variant="ghost"
  488. size="sm"
  489. onClick={() => router.push("/agent/dashboard")}
  490. >
  491. 返回
  492. </Button>
  493. )}
  494. </div>
  495. </div>
  496. );
  497. // 构建主内容区
  498. const mainContent = (
  499. <div className="flex-1 flex overflow-hidden">
  500. {/* 左侧:知识库列表 */}
  501. <div className="w-64 border-r bg-gray-50 flex flex-col">
  502. <div className="p-4 border-b">
  503. <Button
  504. onClick={() => setCreateKBDialogOpen(true)}
  505. className="w-full"
  506. size="sm"
  507. >
  508. <Plus className="w-4 h-4 mr-2" />
  509. 新建知识库
  510. </Button>
  511. </div>
  512. <div className="flex-1 overflow-y-auto p-2">
  513. {loadingKBs ? (
  514. <div className="flex items-center justify-center h-full">
  515. <span className="text-muted-foreground">加载中...</span>
  516. </div>
  517. ) : knowledgeBases.length === 0 ? (
  518. <div className="flex items-center justify-center h-full">
  519. <span className="text-muted-foreground">暂无知识库</span>
  520. </div>
  521. ) : (
  522. <div className="space-y-2">
  523. {knowledgeBases.map((kb) => (
  524. <Card
  525. key={kb.id}
  526. className={`p-3 cursor-pointer transition-colors ${
  527. selectedKnowledgeBase?.id === kb.id
  528. ? "bg-green-50 border-green-300"
  529. : "hover:bg-gray-100"
  530. }`}
  531. onClick={() => handleSelectKnowledgeBase(kb)}
  532. >
  533. <div className="flex items-start justify-between">
  534. <div className="flex-1 min-w-0">
  535. <div className="flex items-center gap-2 mb-1">
  536. <BookOpen className="w-4 h-4 text-blue-600 flex-shrink-0" />
  537. <h3 className="font-medium text-sm truncate">{kb.name}</h3>
  538. </div>
  539. {kb.description && (
  540. <p className="text-xs text-muted-foreground line-clamp-2 mb-1">
  541. {kb.description}
  542. </p>
  543. )}
  544. <div className="flex items-center gap-2 mt-1">
  545. <span className="text-xs text-muted-foreground">
  546. {kb.document_count} 篇文档
  547. </span>
  548. </div>
  549. </div>
  550. <div className="flex flex-col gap-1 ml-2">
  551. <Button
  552. variant="ghost"
  553. size="sm"
  554. className="h-6 w-6 p-0"
  555. onClick={(e) => {
  556. e.stopPropagation();
  557. handleOpenEditKB(kb);
  558. }}
  559. >
  560. <Edit className="w-3 h-3" />
  561. </Button>
  562. <Button
  563. variant="ghost"
  564. size="sm"
  565. className="h-6 w-6 p-0 text-red-600 hover:text-red-700"
  566. onClick={(e) => {
  567. e.stopPropagation();
  568. handleOpenDeleteKB(kb);
  569. }}
  570. >
  571. <Trash2 className="w-3 h-3" />
  572. </Button>
  573. </div>
  574. </div>
  575. </Card>
  576. ))}
  577. </div>
  578. )}
  579. </div>
  580. </div>
  581. {/* 右侧:文档列表 */}
  582. <div className="flex-1 flex flex-col overflow-hidden">
  583. {selectedKnowledgeBase ? (
  584. <>
  585. {/* 文档列表头部 */}
  586. <div className="p-4 border-b bg-white">
  587. <div className="flex items-center justify-between mb-4">
  588. <h2 className="text-lg font-semibold">{selectedKnowledgeBase.name}</h2>
  589. <div className="flex gap-2 items-center">
  590. <div className="flex items-center gap-2 mr-2">
  591. <Label htmlFor="rag-enabled" className="text-sm text-muted-foreground whitespace-nowrap">参与 RAG</Label>
  592. <Switch
  593. id="rag-enabled"
  594. checked={selectedKnowledgeBase.rag_enabled !== false}
  595. onCheckedChange={async (checked) => {
  596. try {
  597. const updated = await updateKnowledgeBaseRAGEnabled(selectedKnowledgeBase.id, checked);
  598. setSelectedKnowledgeBase((prev) => (prev?.id === updated.id ? { ...prev, rag_enabled: updated.rag_enabled } : prev));
  599. setKnowledgeBases((prev) => prev.map((kb) => (kb.id === updated.id ? { ...kb, rag_enabled: updated.rag_enabled } : kb)));
  600. } catch (e) {
  601. toast.error((e as Error).message || "更新失败");
  602. }
  603. }}
  604. />
  605. </div>
  606. <Button
  607. variant="outline"
  608. size="sm"
  609. onClick={() => {
  610. setImportTab("url");
  611. setImportDialogOpen(true);
  612. }}
  613. >
  614. <LinkIcon className="w-4 h-4 mr-2" />
  615. 导入 URL
  616. </Button>
  617. <Button
  618. variant="outline"
  619. size="sm"
  620. onClick={() => {
  621. setImportTab("file");
  622. setImportDialogOpen(true);
  623. }}
  624. >
  625. <Upload className="w-4 h-4 mr-2" />
  626. 导入文件
  627. </Button>
  628. <Button size="sm" onClick={handleOpenCreateDoc}>
  629. <Plus className="w-4 h-4 mr-2" />
  630. 新建文档
  631. </Button>
  632. </div>
  633. </div>
  634. {/* 搜索和筛选 */}
  635. <div className="flex flex-col sm:flex-row items-stretch sm:items-center gap-2">
  636. <div className="flex-1 relative">
  637. <Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-muted-foreground" />
  638. <Input
  639. type="text"
  640. placeholder="搜索文档..."
  641. value={searchKeyword}
  642. onChange={(e) => setSearchKeyword(e.target.value)}
  643. className="pl-10"
  644. />
  645. </div>
  646. <select
  647. value={statusFilter}
  648. onChange={(e) => setStatusFilter(e.target.value)}
  649. className="px-3 py-2 border rounded-md text-sm"
  650. >
  651. <option value="all">全部状态</option>
  652. <option value="draft">草稿</option>
  653. <option value="published">已发布</option>
  654. </select>
  655. </div>
  656. </div>
  657. {/* 文档列表 */}
  658. <div className="flex-1 overflow-y-auto p-4">
  659. {loadingDocs ? (
  660. <div className="flex items-center justify-center h-full">
  661. <span className="text-muted-foreground">加载中...</span>
  662. </div>
  663. ) : (documents?.length ?? 0) === 0 ? (
  664. <div className="flex items-center justify-center h-full">
  665. <span className="text-muted-foreground">
  666. {searchKeyword || statusFilter !== "all"
  667. ? "没有找到匹配的文档"
  668. : "暂无文档"}
  669. </span>
  670. </div>
  671. ) : (
  672. <div className="space-y-4">
  673. {(documents ?? []).map((doc) => (
  674. <Card key={doc.id} className="p-4">
  675. <div className="flex items-start justify-between">
  676. <div className="flex-1 min-w-0">
  677. <div className="flex items-center gap-2 mb-2">
  678. <FileText className="w-5 h-5 text-blue-600 flex-shrink-0" />
  679. <h3 className="font-medium text-foreground truncate">
  680. {doc.title}
  681. </h3>
  682. {getStatusBadge(doc.status)}
  683. {getEmbeddingStatusBadge(doc.embedding_status)}
  684. </div>
  685. {doc.summary && (
  686. <p className="text-sm text-muted-foreground mb-2 line-clamp-2">
  687. {doc.summary}
  688. </p>
  689. )}
  690. <div className="flex items-center gap-4 text-xs text-muted-foreground">
  691. <span>类型: {doc.type}</span>
  692. <span>创建时间: {formatTime(doc.created_at)}</span>
  693. </div>
  694. </div>
  695. <div className="flex flex-col gap-2 ml-4">
  696. <div className="flex gap-2">
  697. <Button
  698. variant="outline"
  699. size="sm"
  700. onClick={() => handleOpenEditDoc(doc)}
  701. >
  702. <Edit className="w-4 h-4 mr-1" />
  703. 编辑
  704. </Button>
  705. <Button
  706. variant="destructive"
  707. size="sm"
  708. onClick={() => handleOpenDeleteDoc(doc)}
  709. >
  710. <Trash2 className="w-4 h-4" />
  711. </Button>
  712. </div>
  713. <div className="flex gap-2">
  714. {doc.status === "published" ? (
  715. <Button
  716. variant="outline"
  717. size="sm"
  718. onClick={() => handleUnpublishDoc(doc.id)}
  719. >
  720. 取消发布
  721. </Button>
  722. ) : (
  723. <Button
  724. variant="outline"
  725. size="sm"
  726. onClick={() => handlePublishDoc(doc.id)}
  727. >
  728. 发布
  729. </Button>
  730. )}
  731. </div>
  732. </div>
  733. </div>
  734. </Card>
  735. ))}
  736. </div>
  737. )}
  738. {/* 分页 */}
  739. {documentResult && documentResult.total_page > 1 && (
  740. <div className="flex items-center justify-center gap-2 mt-4">
  741. <Button
  742. variant="outline"
  743. size="sm"
  744. onClick={() => setCurrentPage(currentPage - 1)}
  745. disabled={currentPage === 1}
  746. >
  747. <ChevronLeft className="w-4 h-4" />
  748. </Button>
  749. <span className="text-sm text-muted-foreground">
  750. 第 {currentPage} / {documentResult.total_page} 页,共 {documentResult.total} 条
  751. </span>
  752. <Button
  753. variant="outline"
  754. size="sm"
  755. onClick={() => setCurrentPage(currentPage + 1)}
  756. disabled={currentPage >= documentResult.total_page}
  757. >
  758. <ChevronRight className="w-4 h-4" />
  759. </Button>
  760. </div>
  761. )}
  762. </div>
  763. </>
  764. ) : (
  765. <div className="flex items-center justify-center h-full">
  766. <span className="text-muted-foreground">请选择一个知识库</span>
  767. </div>
  768. )}
  769. </div>
  770. </div>
  771. );
  772. // 如果是嵌入模式,只返回内容,不包含 ResponsiveLayout
  773. if (embedded) {
  774. return (
  775. <>
  776. <div className="flex-1 flex flex-col min-h-0 overflow-hidden">
  777. {headerContent}
  778. {mainContent}
  779. </div>
  780. {/* 对话框 */}
  781. {/* 创建知识库对话框 */}
  782. <Dialog open={createKBDialogOpen} onOpenChange={setCreateKBDialogOpen}>
  783. <DialogContent className="max-w-2xl">
  784. <DialogHeader>
  785. <DialogTitle>创建知识库</DialogTitle>
  786. <DialogDescription>填写知识库名称和描述</DialogDescription>
  787. </DialogHeader>
  788. <div className="space-y-4">
  789. <div>
  790. <Label htmlFor="create-kb-name">名称 *</Label>
  791. <Input
  792. id="create-kb-name"
  793. value={createKBForm.name}
  794. onChange={(e) =>
  795. setCreateKBForm({ ...createKBForm, name: e.target.value })
  796. }
  797. placeholder="请输入知识库名称"
  798. />
  799. </div>
  800. <div>
  801. <Label htmlFor="create-kb-desc">描述(可选)</Label>
  802. <Textarea
  803. id="create-kb-desc"
  804. value={createKBForm.description || ""}
  805. onChange={(e) =>
  806. setCreateKBForm({ ...createKBForm, description: e.target.value })
  807. }
  808. placeholder="请输入知识库描述"
  809. rows={3}
  810. />
  811. </div>
  812. <div className="flex justify-end gap-2">
  813. <Button
  814. variant="outline"
  815. onClick={() => setCreateKBDialogOpen(false)}
  816. disabled={submitting}
  817. >
  818. 取消
  819. </Button>
  820. <Button onClick={handleCreateKB} disabled={submitting}>
  821. {submitting ? "创建中..." : "创建"}
  822. </Button>
  823. </div>
  824. </div>
  825. </DialogContent>
  826. </Dialog>
  827. {/* 编辑知识库对话框 */}
  828. <Dialog open={editKBDialogOpen} onOpenChange={setEditKBDialogOpen}>
  829. <DialogContent className="max-w-2xl">
  830. <DialogHeader>
  831. <DialogTitle>编辑知识库</DialogTitle>
  832. <DialogDescription>修改知识库名称和描述</DialogDescription>
  833. </DialogHeader>
  834. {selectedKnowledgeBase && (
  835. <div className="space-y-4">
  836. <div>
  837. <Label htmlFor="edit-kb-name">名称 *</Label>
  838. <Input
  839. id="edit-kb-name"
  840. value={editKBForm.name || selectedKnowledgeBase.name}
  841. onChange={(e) =>
  842. setEditKBForm({ ...editKBForm, name: e.target.value })
  843. }
  844. placeholder="请输入知识库名称"
  845. />
  846. </div>
  847. <div>
  848. <Label htmlFor="edit-kb-desc">描述(可选)</Label>
  849. <Textarea
  850. id="edit-kb-desc"
  851. value={editKBForm.description ?? selectedKnowledgeBase.description ?? ""}
  852. onChange={(e) =>
  853. setEditKBForm({ ...editKBForm, description: e.target.value })
  854. }
  855. placeholder="请输入知识库描述"
  856. rows={3}
  857. />
  858. </div>
  859. <div className="flex justify-end gap-2">
  860. <Button
  861. variant="outline"
  862. onClick={() => setEditKBDialogOpen(false)}
  863. disabled={submitting}
  864. >
  865. 取消
  866. </Button>
  867. <Button onClick={handleUpdateKB} disabled={submitting}>
  868. {submitting ? "更新中..." : "更新"}
  869. </Button>
  870. </div>
  871. </div>
  872. )}
  873. </DialogContent>
  874. </Dialog>
  875. {/* 删除知识库对话框 */}
  876. <Dialog open={deleteKBDialogOpen} onOpenChange={setDeleteKBDialogOpen}>
  877. <DialogContent>
  878. <DialogHeader>
  879. <DialogTitle>删除知识库</DialogTitle>
  880. </DialogHeader>
  881. {selectedKnowledgeBase && (
  882. <div className="space-y-4">
  883. <p className="text-foreground">
  884. 确定要删除知识库 <strong>&quot;{selectedKnowledgeBase.name}&quot;</strong> 吗?
  885. </p>
  886. <p className="text-sm text-muted-foreground">
  887. 此操作将同时删除该知识库下的所有文档,此操作不可恢复,请谨慎操作。
  888. </p>
  889. <div className="flex justify-end gap-2">
  890. <Button
  891. variant="outline"
  892. onClick={() => setDeleteKBDialogOpen(false)}
  893. disabled={submitting}
  894. >
  895. 取消
  896. </Button>
  897. <Button
  898. variant="destructive"
  899. onClick={handleDeleteKB}
  900. disabled={submitting}
  901. >
  902. {submitting ? "删除中..." : "删除"}
  903. </Button>
  904. </div>
  905. </div>
  906. )}
  907. </DialogContent>
  908. </Dialog>
  909. {/* 创建文档对话框 */}
  910. <Dialog open={createDocDialogOpen} onOpenChange={setCreateDocDialogOpen}>
  911. <DialogContent className="max-w-3xl max-h-[90vh] overflow-y-auto">
  912. <DialogHeader>
  913. <DialogTitle>创建文档</DialogTitle>
  914. <DialogDescription>填写文档标题和内容</DialogDescription>
  915. </DialogHeader>
  916. <div className="space-y-4">
  917. <div>
  918. <Label htmlFor="create-doc-title">标题 *</Label>
  919. <Input
  920. id="create-doc-title"
  921. value={createDocForm.title}
  922. onChange={(e) =>
  923. setCreateDocForm({ ...createDocForm, title: e.target.value })
  924. }
  925. placeholder="请输入文档标题"
  926. />
  927. </div>
  928. <div>
  929. <Label htmlFor="create-doc-summary">摘要(可选)</Label>
  930. <Textarea
  931. id="create-doc-summary"
  932. value={createDocForm.summary || ""}
  933. onChange={(e) =>
  934. setCreateDocForm({ ...createDocForm, summary: e.target.value })
  935. }
  936. placeholder="请输入文档摘要"
  937. rows={2}
  938. />
  939. </div>
  940. <div>
  941. <Label htmlFor="create-doc-content">内容 *</Label>
  942. <Textarea
  943. id="create-doc-content"
  944. value={createDocForm.content}
  945. onChange={(e) =>
  946. setCreateDocForm({ ...createDocForm, content: e.target.value })
  947. }
  948. placeholder="请输入文档内容"
  949. rows={10}
  950. className="resize-none"
  951. />
  952. </div>
  953. <div className="flex justify-end gap-2">
  954. <Button
  955. variant="outline"
  956. onClick={() => setCreateDocDialogOpen(false)}
  957. disabled={submitting}
  958. >
  959. 取消
  960. </Button>
  961. <Button onClick={handleCreateDoc} disabled={submitting}>
  962. {submitting ? "创建中..." : "创建"}
  963. </Button>
  964. </div>
  965. </div>
  966. </DialogContent>
  967. </Dialog>
  968. {/* 编辑文档对话框 */}
  969. <Dialog open={editDocDialogOpen} onOpenChange={setEditDocDialogOpen}>
  970. <DialogContent className="max-w-3xl max-h-[90vh] overflow-y-auto">
  971. <DialogHeader>
  972. <DialogTitle>编辑文档</DialogTitle>
  973. <DialogDescription>修改文档标题和内容</DialogDescription>
  974. </DialogHeader>
  975. <div className="space-y-4">
  976. <div>
  977. <Label htmlFor="edit-doc-title">标题 *</Label>
  978. <Input
  979. id="edit-doc-title"
  980. value={editDocForm.title || ""}
  981. onChange={(e) =>
  982. setEditDocForm({ ...editDocForm, title: e.target.value })
  983. }
  984. placeholder="请输入文档标题"
  985. />
  986. </div>
  987. <div>
  988. <Label htmlFor="edit-doc-summary">摘要(可选)</Label>
  989. <Textarea
  990. id="edit-doc-summary"
  991. value={editDocForm.summary || ""}
  992. onChange={(e) =>
  993. setEditDocForm({ ...editDocForm, summary: e.target.value })
  994. }
  995. placeholder="请输入文档摘要"
  996. rows={2}
  997. />
  998. </div>
  999. <div>
  1000. <Label htmlFor="edit-doc-content">内容 *</Label>
  1001. <Textarea
  1002. id="edit-doc-content"
  1003. value={editDocForm.content || ""}
  1004. onChange={(e) =>
  1005. setEditDocForm({ ...editDocForm, content: e.target.value })
  1006. }
  1007. placeholder="请输入文档内容"
  1008. rows={10}
  1009. className="resize-none"
  1010. />
  1011. </div>
  1012. <div className="flex justify-end gap-2">
  1013. <Button
  1014. variant="outline"
  1015. onClick={() => setEditDocDialogOpen(false)}
  1016. disabled={submitting}
  1017. >
  1018. 取消
  1019. </Button>
  1020. <Button
  1021. onClick={() => {
  1022. if (selectedDocument) {
  1023. handleUpdateDoc(selectedDocument.id);
  1024. }
  1025. }}
  1026. disabled={submitting}
  1027. >
  1028. {submitting ? "更新中..." : "更新"}
  1029. </Button>
  1030. </div>
  1031. </div>
  1032. </DialogContent>
  1033. </Dialog>
  1034. {/* 删除文档对话框 */}
  1035. <Dialog open={deleteDocDialogOpen} onOpenChange={setDeleteDocDialogOpen}>
  1036. <DialogContent>
  1037. <DialogHeader>
  1038. <DialogTitle>删除文档</DialogTitle>
  1039. </DialogHeader>
  1040. {selectedDocument && (
  1041. <div className="space-y-4">
  1042. <p className="text-foreground">
  1043. 确定要删除文档 <strong>&quot;{selectedDocument.title}&quot;</strong> 吗?
  1044. </p>
  1045. <p className="text-sm text-muted-foreground">
  1046. 此操作不可恢复,请谨慎操作。
  1047. </p>
  1048. </div>
  1049. )}
  1050. <div className="flex justify-end gap-2">
  1051. <Button
  1052. variant="outline"
  1053. onClick={() => setDeleteDocDialogOpen(false)}
  1054. disabled={submitting}
  1055. >
  1056. 取消
  1057. </Button>
  1058. <Button
  1059. variant="destructive"
  1060. onClick={() => {
  1061. if (selectedDocument) {
  1062. handleDeleteDoc(selectedDocument.id);
  1063. }
  1064. }}
  1065. disabled={submitting}
  1066. >
  1067. {submitting ? "删除中..." : "删除"}
  1068. </Button>
  1069. </div>
  1070. </DialogContent>
  1071. </Dialog>
  1072. {/* 导入文档对话框(文件上传 + URL 导入) */}
  1073. <Dialog
  1074. open={importDialogOpen}
  1075. onOpenChange={(open) => {
  1076. setImportDialogOpen(open);
  1077. if (!open) {
  1078. setImportFiles([]);
  1079. setImportUrls("");
  1080. setImportTab("file");
  1081. }
  1082. }}
  1083. >
  1084. <DialogContent className="max-w-2xl">
  1085. <DialogHeader>
  1086. <DialogTitle>导入文档</DialogTitle>
  1087. <DialogDescription>
  1088. 选择文件上传或输入 URL 批量导入。当前支持的文件格式:<strong>Markdown(.md、.markdown)</strong>;PDF、Word 解析功能开发中。
  1089. </DialogDescription>
  1090. </DialogHeader>
  1091. <Tabs
  1092. value={importTab}
  1093. onValueChange={(v) => setImportTab(v as "file" | "url")}
  1094. defaultValue="file"
  1095. >
  1096. <TabsList className="grid w-full grid-cols-2">
  1097. <TabsTrigger value="file">文件上传</TabsTrigger>
  1098. <TabsTrigger value="url">URL 导入</TabsTrigger>
  1099. </TabsList>
  1100. <TabsContent value="file" className="space-y-4 mt-4">
  1101. <div>
  1102. <Label htmlFor="import-files">选择文件</Label>
  1103. <Input
  1104. id="import-files"
  1105. type="file"
  1106. multiple
  1107. onChange={(e) => {
  1108. const files = Array.from(e.target.files || []);
  1109. setImportFiles(files);
  1110. }}
  1111. />
  1112. {importFiles.length > 0 && (
  1113. <p className="text-sm text-muted-foreground mt-2">
  1114. 已选择 {importFiles.length} 个文件
  1115. </p>
  1116. )}
  1117. </div>
  1118. <div className="flex justify-end gap-2">
  1119. <Button
  1120. variant="outline"
  1121. onClick={() => setImportDialogOpen(false)}
  1122. disabled={submitting}
  1123. >
  1124. 取消
  1125. </Button>
  1126. <Button onClick={handleImportFiles} disabled={submitting}>
  1127. {submitting ? "导入中..." : "导入"}
  1128. </Button>
  1129. </div>
  1130. </TabsContent>
  1131. <TabsContent value="url" className="space-y-4 mt-4">
  1132. <div>
  1133. <Label htmlFor="import-urls">URL 列表(每行一个)</Label>
  1134. <Textarea
  1135. id="import-urls"
  1136. value={importUrls}
  1137. onChange={(e) => setImportUrls(e.target.value)}
  1138. placeholder="https://example.com/page1&#10;https://example.com/page2"
  1139. rows={8}
  1140. className="resize-none"
  1141. />
  1142. </div>
  1143. <div className="flex justify-end gap-2">
  1144. <Button
  1145. variant="outline"
  1146. onClick={() => setImportDialogOpen(false)}
  1147. disabled={submitting}
  1148. >
  1149. 取消
  1150. </Button>
  1151. <Button onClick={handleImportUrls} disabled={submitting}>
  1152. {submitting ? "导入中..." : "导入"}
  1153. </Button>
  1154. </div>
  1155. </TabsContent>
  1156. </Tabs>
  1157. </DialogContent>
  1158. </Dialog>
  1159. </>
  1160. );
  1161. }
  1162. return (
  1163. <ResponsiveLayout
  1164. main={mainContent}
  1165. header={headerContent}
  1166. />
  1167. );
  1168. }