page.tsx 44 KB

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