KnowledgePage.tsx 73 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702
  1. import * as React from "react";
  2. import { useTranslation } from "react-i18next";
  3. import { NavLink, useNavigate, useParams } from "react-router-dom";
  4. import {
  5. Archive,
  6. BarChart3,
  7. BookOpen,
  8. Bot,
  9. ClipboardCheck,
  10. Database,
  11. FilePlus,
  12. FileSearch,
  13. FileText,
  14. Filter,
  15. Gauge,
  16. Layers3,
  17. ListChecks,
  18. RefreshCw,
  19. RotateCcw,
  20. Search,
  21. Settings2,
  22. ShieldCheck,
  23. SlidersHorizontal,
  24. Sparkles,
  25. UploadCloud,
  26. type LucideIcon,
  27. } from "lucide-react";
  28. import {
  29. createKnowledgeBase,
  30. createKnowledgeDocument,
  31. getKnowledgeSettings,
  32. listKnowledgeIndexJobs,
  33. listKnowledgeBases,
  34. listKnowledgeChunks,
  35. listKnowledgeDocuments,
  36. listModels,
  37. parseKnowledgeDocument,
  38. reindexKnowledgeBase,
  39. searchKnowledge,
  40. updateKnowledgeSettings,
  41. updateKnowledgeBaseStatus,
  42. } from "@/api";
  43. import { translateApiError } from "@/api/errors";
  44. import { ApiErrorState } from "@/components/shared/ApiErrorState";
  45. import { EmptyState } from "@/components/shared/EmptyState";
  46. import { LoadingSpinner } from "@/components/shared/LoadingSpinner";
  47. import { MetricCard } from "@/components/shared/MetricCard";
  48. import { PageHeader } from "@/components/shared/PageHeader";
  49. import { SearchInput } from "@/components/shared/SearchInput";
  50. import { StatusBadge } from "@/components/shared/StatusBadge";
  51. import { Badge } from "@/components/ui/badge";
  52. import { Button } from "@/components/ui/button";
  53. import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
  54. import { Dialog } from "@/components/ui/dialog";
  55. import { Input, Textarea } from "@/components/ui/input";
  56. import { Select } from "@/components/ui/select";
  57. import { Tabs } from "@/components/ui/tabs";
  58. import { toast } from "@/components/ui/toaster";
  59. import { demoText } from "@/lib/demo-text";
  60. import { formatDateTime } from "@/lib/utils";
  61. import type { JSONObject, KnowledgeBase, KnowledgeChunk, KnowledgeDocument, KnowledgeDocumentIngestResponse, KnowledgeDocumentParseResponse, KnowledgeIndexJob, ModelDefinition, SearchResult } from "@/types";
  62. import type { KnowledgeSettingsPayload } from "@/api/knowledge";
  63. const documentStatusValues = ["all", "queued", "indexing", "indexed", "draft", "failed", "archived"] as const;
  64. const sourceTypeValues = ["all", "text", "markdown", "json", "html", "pdf"] as const;
  65. type KnowledgeJob = {
  66. id: string;
  67. type: string;
  68. target: string;
  69. status: KnowledgeIndexJob["status"];
  70. progress: number;
  71. createdTime: string;
  72. workerKey?: string | null;
  73. errorMessage?: string | null;
  74. };
  75. type EvalCase = {
  76. id: string;
  77. query: string;
  78. expected: string;
  79. status: "draft" | "passed" | "failed";
  80. recall: number;
  81. precision: number;
  82. };
  83. type RetrievalConfig = {
  84. retrievalMode: string;
  85. embeddingModelId: string;
  86. rerankModelId: string;
  87. chunkSize: string;
  88. chunkOverlap: string;
  89. topK: string;
  90. minScore: string;
  91. maxCandidates: string;
  92. keywordWeight: string;
  93. vectorWeight: string;
  94. rerankWeight: string;
  95. queryRewrite: boolean;
  96. requireCitations: boolean;
  97. };
  98. const defaultRetrievalConfig: RetrievalConfig = {
  99. retrievalMode: "hybrid",
  100. embeddingModelId: "auto",
  101. rerankModelId: "auto",
  102. chunkSize: "800",
  103. chunkOverlap: "120",
  104. topK: "5",
  105. minScore: "0.20",
  106. maxCandidates: "50",
  107. keywordWeight: "0.30",
  108. vectorWeight: "0.55",
  109. rerankWeight: "0.15",
  110. queryRewrite: true,
  111. requireCitations: true,
  112. };
  113. const knowledgeSections = [
  114. { value: "overview", label: "Knowledge Bases", path: "/knowledge", icon: Database },
  115. { value: "documents", label: "Documents", path: "/knowledge/documents", icon: FileText },
  116. { value: "playground", label: "Test Search", path: "/knowledge/playground", icon: Search },
  117. { value: "evaluation", label: "Quality", path: "/knowledge/evaluation", icon: ClipboardCheck },
  118. { value: "jobs", label: "Index Jobs", path: "/knowledge/jobs", icon: ListChecks },
  119. { value: "analytics", label: "Analytics", path: "/knowledge/analytics", icon: BarChart3 },
  120. { value: "settings", label: "Settings", path: "/knowledge/settings", icon: Settings2 },
  121. ];
  122. const capabilityGroups = [
  123. {
  124. titleKey: "knowledge.ingestion",
  125. icon: UploadCloud,
  126. items: [
  127. { labelKey: "knowledge.textMarkdownIngest", state: "live" },
  128. { labelKey: "knowledge.parsePreviewIng", state: "live" },
  129. { labelKey: "knowledge.pdfDocxHtmlParser", state: "prototype" },
  130. { labelKey: "knowledge.urlSitemapGithub", state: "prototype" },
  131. ],
  132. },
  133. {
  134. titleKey: "knowledge.indexingTab",
  135. icon: Layers3,
  136. items: [
  137. { labelKey: "knowledge.chunkSizeOverlap", state: "live" },
  138. { labelKey: "knowledge.embeddingModelTracking", state: "mock" },
  139. { labelKey: "knowledge.reindexDocumentBase", state: "prototype" },
  140. { labelKey: "knowledge.indexJobQueue", state: "prototype" },
  141. ],
  142. },
  143. {
  144. titleKey: "knowledge.retrievalTab",
  145. icon: Search,
  146. items: [
  147. { labelKey: "knowledge.hybridSearchPlayground", state: "live" },
  148. { labelKey: "knowledge.topKMetadataFilters", state: "live" },
  149. { labelKey: "knowledge.scoreBreakdownCitations", state: "live" },
  150. { labelKey: "knowledge.rerankQueryRewrite", state: "mock" },
  151. ],
  152. },
  153. {
  154. titleKey: "knowledge.evaluation",
  155. icon: ClipboardCheck,
  156. items: [
  157. { labelKey: "knowledge.goldenQueries", state: "prototype" },
  158. { labelKey: "knowledge.recallPrecisionMetrics", state: "prototype" },
  159. { labelKey: "knowledge.humanRelevanceFeedback", state: "prototype" },
  160. { labelKey: "knowledge.regressionComparison", state: "prototype" },
  161. ],
  162. },
  163. {
  164. titleKey: "knowledge.governance",
  165. icon: ShieldCheck,
  166. items: [
  167. { labelKey: "knowledge.archiveRestoreBase", state: "live" },
  168. { labelKey: "knowledge.documentAclPii", state: "prototype" },
  169. { labelKey: "knowledge.auditLog", state: "prototype" },
  170. ],
  171. },
  172. {
  173. titleKey: "knowledge.integration",
  174. icon: Bot,
  175. items: [
  176. { labelKey: "knowledge.agentBinding", state: "prototype" },
  177. { labelKey: "knowledge.workflowRetrievalNode", state: "prototype" },
  178. { labelKey: "knowledge.knowledgeSearchTool", state: "prototype" },
  179. { labelKey: "knowledge.runTraceCitations", state: "prototype" },
  180. ],
  181. },
  182. ];
  183. export function KnowledgePage() {
  184. const { t } = useTranslation();
  185. const navigate = useNavigate();
  186. const documentStatusOptions = documentStatusValues.map((value) => ({
  187. value,
  188. label: t(`knowledge.statusLabels.${value}`),
  189. }));
  190. const sourceTypeOptions = sourceTypeValues.map((value) => ({
  191. value,
  192. label: t(`knowledge.sourceLabels.${value}`),
  193. }));
  194. const { section: sectionParam } = useParams();
  195. const section = knowledgeSections.some((item) => item.value === sectionParam) ? sectionParam ?? "overview" : "overview";
  196. const [bases, setBases] = React.useState<KnowledgeBase[]>([]);
  197. const [documents, setDocuments] = React.useState<KnowledgeDocument[]>([]);
  198. const [chunksByDocument, setChunksByDocument] = React.useState<Record<string, KnowledgeChunk[]>>({});
  199. const [models, setModels] = React.useState<ModelDefinition[]>([]);
  200. const [results, setResults] = React.useState<SearchResult[]>([]);
  201. const [selectedBaseId, setSelectedBaseId] = React.useState<string>();
  202. const [scopeMode] = React.useState<"current" | "all">("current");
  203. const [selectedDocumentId, setSelectedDocumentId] = React.useState<string>();
  204. const [baseSearch, setBaseSearch] = React.useState("");
  205. const [documentSearch, setDocumentSearch] = React.useState("");
  206. const [documentStatus, setDocumentStatus] = React.useState("all");
  207. const [sourceType, setSourceType] = React.useState("all");
  208. const [query, setQuery] = React.useState("");
  209. const [topK, setTopK] = React.useState("5");
  210. const [activeTab, setActiveTab] = React.useState("overview");
  211. const [loading, setLoading] = React.useState(true);
  212. const [documentsLoading, setDocumentsLoading] = React.useState(false);
  213. const [searching, setSearching] = React.useState(false);
  214. const [statusBusy, setStatusBusy] = React.useState(false);
  215. const [error, setError] = React.useState<string>();
  216. const [createOpen, setCreateOpen] = React.useState(false);
  217. const [documentOpen, setDocumentOpen] = React.useState(false);
  218. const [lastIngest, setLastIngest] = React.useState<KnowledgeDocumentIngestResponse>();
  219. const [jobs, setJobs] = React.useState<KnowledgeJob[]>([]);
  220. const [evalCases, setEvalCases] = React.useState<EvalCase[]>([]);
  221. const [retrievalConfig, setRetrievalConfig] = React.useState<RetrievalConfig>(defaultRetrievalConfig);
  222. const selectedBase = bases.find((base) => base.id === selectedBaseId);
  223. const selectedBases = scopeMode === "all" ? bases : selectedBase ? [selectedBase] : [];
  224. const activeBaseIds = scopeMode === "all" ? bases.map((base) => base.id) : selectedBaseId ? [selectedBaseId] : [];
  225. const activeBaseKey = activeBaseIds.join("|");
  226. const baseNameById = React.useMemo(() => new Map(bases.map((base) => [base.id, demoText(base.name, t)])), [bases, t]);
  227. const selectedDocument = documents.find((document) => document.id === selectedDocumentId);
  228. const sourceTypes = React.useMemo(() => Array.from(new Set(documents.map((document) => document.sourceType))).sort(), [documents]);
  229. const indexedCount = documents.filter((document) => document.status === "indexed").length;
  230. const filteredBases = bases.filter((base) => `${base.name} ${base.description ?? ""}`.toLowerCase().includes(baseSearch.toLowerCase()));
  231. const filteredDocuments = documents.filter((document) => {
  232. const matchesText = `${document.title} ${demoText(document.title, t)} ${document.sourceUri ?? ""} ${document.sourceType}`.toLowerCase().includes(documentSearch.toLowerCase());
  233. const matchesStatus = documentStatus === "all" || document.status === documentStatus;
  234. const matchesSource = sourceType === "all" || document.sourceType === sourceType;
  235. return matchesText && matchesStatus && matchesSource;
  236. });
  237. const selectedDocumentResults = selectedDocument
  238. ? results.filter((result) => result.document.id === selectedDocument.id || result.chunk.documentId === selectedDocument.id)
  239. : [];
  240. const selectedDocumentChunks = React.useMemo(() => {
  241. const chunks = [
  242. ...(selectedDocumentId ? chunksByDocument[selectedDocumentId] ?? [] : []),
  243. ...(lastIngest && lastIngest.document.id === selectedDocumentId ? lastIngest.chunks : []),
  244. ...results.filter((result) => result.chunk.documentId === selectedDocumentId).map((result) => result.chunk),
  245. ];
  246. return Array.from(new Map(chunks.map((chunk) => [chunk.id, chunk])).values());
  247. }, [chunksByDocument, lastIngest, results, selectedDocumentId]);
  248. const loadBases = React.useCallback(async () => {
  249. setLoading(true);
  250. setError(undefined);
  251. try {
  252. const data = await listKnowledgeBases();
  253. setBases(data);
  254. setSelectedBaseId((current) => current ?? data[0]?.id);
  255. } catch (err) {
  256. setError(translateApiError(err));
  257. } finally {
  258. setLoading(false);
  259. }
  260. }, [t]);
  261. const loadDocuments = React.useCallback(async (knowledgeBaseIds?: string[]) => {
  262. if (!knowledgeBaseIds?.length) {
  263. setDocuments([]);
  264. setChunksByDocument({});
  265. setResults([]);
  266. setSelectedDocumentId(undefined);
  267. return;
  268. }
  269. setDocumentsLoading(true);
  270. try {
  271. const [documentGroups, chunkGroups] = await Promise.all([
  272. Promise.all(knowledgeBaseIds.map((knowledgeBaseId) => listKnowledgeDocuments(knowledgeBaseId))),
  273. Promise.all(knowledgeBaseIds.map((knowledgeBaseId) => listKnowledgeChunks(knowledgeBaseId).catch(() => [] as KnowledgeChunk[]))),
  274. ]);
  275. const data = documentGroups.flat();
  276. setDocuments(data);
  277. setChunksByDocument(groupChunksByDocument(chunkGroups.flat()));
  278. setSelectedDocumentId((current) => (current && data.some((document) => document.id === current) ? current : data[0]?.id));
  279. setResults([]);
  280. } catch {
  281. setDocuments([]);
  282. setChunksByDocument({});
  283. setSelectedDocumentId(undefined);
  284. toast.error(t("knowledge.failedToLoadDocuments"));
  285. } finally {
  286. setDocumentsLoading(false);
  287. }
  288. }, [t]);
  289. const loadJobs = React.useCallback(async (knowledgeBaseIds?: string[]) => {
  290. if (!knowledgeBaseIds?.length) {
  291. setJobs([]);
  292. return;
  293. }
  294. try {
  295. const jobGroups = await Promise.all(knowledgeBaseIds.map((knowledgeBaseId) => listKnowledgeIndexJobs(knowledgeBaseId)));
  296. setJobs(jobGroups.flat().map(toKnowledgeJob));
  297. } catch {
  298. setJobs([]);
  299. }
  300. }, []);
  301. React.useEffect(() => {
  302. void loadBases();
  303. }, [loadBases]);
  304. React.useEffect(() => {
  305. let mounted = true;
  306. async function loadConfiguredModels() {
  307. try {
  308. const data = await listModels();
  309. if (mounted) setModels(data);
  310. } catch {
  311. if (mounted) setModels([]);
  312. }
  313. }
  314. void loadConfiguredModels();
  315. return () => {
  316. mounted = false;
  317. };
  318. }, []);
  319. React.useEffect(() => {
  320. void loadDocuments(activeBaseIds);
  321. }, [loadDocuments, activeBaseKey]);
  322. React.useEffect(() => {
  323. void loadJobs(activeBaseIds);
  324. }, [loadJobs, activeBaseKey]);
  325. React.useEffect(() => {
  326. let mounted = true;
  327. async function loadRetrievalSettings() {
  328. if (!selectedBaseId) {
  329. if (mounted) setRetrievalConfig(defaultRetrievalConfig);
  330. return;
  331. }
  332. try {
  333. const data = await getKnowledgeSettings(selectedBaseId);
  334. if (mounted) setRetrievalConfig(settingsPayloadToForm(data));
  335. } catch {
  336. if (mounted) setRetrievalConfig(defaultRetrievalConfig);
  337. }
  338. }
  339. void loadRetrievalSettings();
  340. return () => {
  341. mounted = false;
  342. };
  343. }, [selectedBaseId]);
  344. async function runSearch(event?: React.FormEvent) {
  345. event?.preventDefault();
  346. if (!activeBaseIds.length || !query.trim()) return;
  347. setSearching(true);
  348. try {
  349. const filters: JSONObject = {};
  350. if (documentStatus !== "all") filters.status = documentStatus;
  351. if (sourceType !== "all") filters.sourceType = sourceType;
  352. const data = (await Promise.all(activeBaseIds.map((baseId) => searchKnowledge(baseId, query.trim(), Number(topK) || 5, filters)))).flat();
  353. data.sort((a, b) => b.score - a.score);
  354. setResults(data);
  355. setActiveTab("search");
  356. if (section !== "playground") navigate("/knowledge/playground");
  357. toast.info(data.length ? t("knowledge.searchResults", { count: data.length }) : t("knowledge.noMatchingChunks"));
  358. } catch (err) {
  359. toast.error(translateApiError(err));
  360. } finally {
  361. setSearching(false);
  362. }
  363. }
  364. async function reloadSelectedBase() {
  365. await Promise.all([loadBases(), loadDocuments(activeBaseIds), loadJobs(activeBaseIds)]);
  366. }
  367. function selectBase(baseId: string) {
  368. setSelectedBaseId(baseId);
  369. setSelectedDocumentId(undefined);
  370. }
  371. async function toggleBaseStatus() {
  372. if (!selectedBase) return;
  373. setStatusBusy(true);
  374. try {
  375. const nextStatus = selectedBase.status === "active" ? "archived" : "active";
  376. const updated = await updateKnowledgeBaseStatus({ knowledgeBaseId: selectedBase.id, status: nextStatus });
  377. setBases((items) => items.map((base) => (base.id === updated.id ? updated : base)));
  378. toast.success(nextStatus === "active" ? t("knowledge.knowledgeBaseRestored") : t("knowledge.knowledgeBaseArchived"));
  379. } finally {
  380. setStatusBusy(false);
  381. }
  382. }
  383. async function createJobsForActiveBases(type: string) {
  384. const targets = selectedBases.length ? selectedBases : selectedBase ? [selectedBase] : [];
  385. for (const base of targets) {
  386. try {
  387. const result = await reindexKnowledgeBase({
  388. knowledgeBaseId: base.id,
  389. chunkSize: Number(retrievalConfig.chunkSize) || undefined,
  390. chunkOverlap: Number(retrievalConfig.chunkOverlap) || undefined,
  391. });
  392. setJobs((items) => [...result.jobs.map(toKnowledgeJob), ...items.filter((item) => !result.jobs.some((job) => job.jobId === item.id))]);
  393. toast.success(t("knowledge.createdJob", { type: formatKnowledgeJobType(type, t) }));
  394. await Promise.all([loadDocuments(activeBaseIds), loadJobs(activeBaseIds)]);
  395. } catch {
  396. toast.error(t("knowledge.documentIngestFailed"));
  397. }
  398. }
  399. }
  400. function addEvalCase(queryText: string, expected: string) {
  401. setEvalCases((items) => [
  402. { id: `eval_${Date.now().toString(36)}`, query: queryText, expected, status: "draft", recall: 0, precision: 0 },
  403. ...items,
  404. ]);
  405. }
  406. async function runEvalCase(evalId: string) {
  407. const item = evalCases.find((evalCase) => evalCase.id === evalId);
  408. if (!item || !activeBaseIds.length) return;
  409. const data = (await Promise.all(activeBaseIds.map((baseId) => searchKnowledge(baseId, item.query, Number(topK) || 5)))).flat();
  410. const expected = item.expected.toLowerCase();
  411. const matched = data.some((result) =>
  412. `${result.document.title} ${result.chunk.contentText}`.toLowerCase().includes(expected));
  413. const scoreAverage = average(data.map((result) => result.score));
  414. setEvalCases((items) =>
  415. items.map((evalCase) =>
  416. evalCase.id === evalId
  417. ? {
  418. ...evalCase,
  419. status: matched ? "passed" : "failed",
  420. recall: matched ? 1 : 0,
  421. precision: Number(Math.min(scoreAverage || 0, 1).toFixed(2)),
  422. }
  423. : evalCase));
  424. toast.success(t("knowledge.evaluationCaseFinished"));
  425. }
  426. function updateRetrievalConfig(nextConfig: RetrievalConfig) {
  427. setRetrievalConfig(nextConfig);
  428. if (!selectedBaseId) return;
  429. void updateKnowledgeSettings(formToSettingsPayload(nextConfig, selectedBaseId)).catch(() => {
  430. toast.error(t("common.failedToSave"));
  431. });
  432. }
  433. const isOverview = section === "overview";
  434. const pageTitle = isOverview ? t("knowledge.knowledgeBases") : demoText(selectedBase?.name, t) || t("knowledge.title");
  435. const pageDescription = isOverview
  436. ? t("knowledge.description")
  437. : t("knowledge.manageInsideBase");
  438. if (loading) return <LoadingSpinner label={t("knowledge.loading")} />;
  439. if (error) return <ApiErrorState message={error} onRetry={() => void loadBases()} />;
  440. return (
  441. <div className="space-y-6">
  442. <PageHeader
  443. title={pageTitle}
  444. description={pageDescription}
  445. actions={(
  446. <div className="flex flex-wrap gap-2">
  447. {!isOverview ? (
  448. <Button variant="secondary" onClick={() => navigate("/knowledge")}>
  449. <Database className="h-4 w-4" /> {t("knowledge.manageBases")}
  450. </Button>
  451. ) : null}
  452. {isOverview ? <Button onClick={() => setCreateOpen(true)}><BookOpen className="h-4 w-4" /> {t("knowledge.newBase")}</Button> : null}
  453. <Button size="icon" variant="ghost" aria-label={t("knowledge.refresh")} onClick={() => void reloadSelectedBase()}>
  454. <RefreshCw className="h-4 w-4" />
  455. </Button>
  456. </div>
  457. )}
  458. />
  459. {!isOverview ? (
  460. <>
  461. <KnowledgeScopeBar
  462. bases={bases}
  463. selectedBaseId={selectedBaseId}
  464. documentCount={documents.length}
  465. indexedCount={indexedCount}
  466. jobCount={jobs.length}
  467. onSelectBase={selectBase}
  468. onAddDocument={() => setDocumentOpen(true)}
  469. onTestSearch={() => navigate("/knowledge/playground")}
  470. onReindex={() => createJobsForActiveBases("Re-index")}
  471. onSettings={() => navigate("/knowledge/settings")}
  472. />
  473. <KnowledgeSectionNav />
  474. </>
  475. ) : null}
  476. {section === "overview" ? (
  477. <div className="space-y-6">
  478. <div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
  479. <MetricCard label={t("knowledge.knowledgeBases")} value={bases.length} icon={Database} />
  480. <MetricCard label={t("knowledge.documents")} value={documents.length} icon={FileText} />
  481. <MetricCard label={t("knowledge.indexed")} value={indexedCount} icon={Sparkles} />
  482. <MetricCard label={t("knowledge.sources")} value={sourceTypes.length} icon={Filter} />
  483. </div>
  484. <div className="grid gap-6 2xl:grid-cols-[minmax(280px,360px)_minmax(0,1fr)]">
  485. <KnowledgeBaseList
  486. bases={filteredBases}
  487. baseSearch={baseSearch}
  488. onSearch={setBaseSearch}
  489. selectedBaseId={selectedBaseId}
  490. onCreate={() => setCreateOpen(true)}
  491. onSelect={selectBase}
  492. />
  493. <div className="space-y-6">
  494. <BaseSummaryCard selectedBase={selectedBase} selectedBases={selectedBases} documents={documents} indexedCount={indexedCount} statusBusy={statusBusy} onToggleStatus={() => void toggleBaseStatus()} onOpenBase={() => navigate("/knowledge/documents")} />
  495. <KnowledgeCapabilityBoard compact />
  496. </div>
  497. </div>
  498. </div>
  499. ) : null}
  500. {section === "documents" ? (
  501. <div className="grid gap-6 2xl:grid-cols-[minmax(0,1fr)_420px]">
  502. <DocumentsPanel
  503. documentsLoading={documentsLoading}
  504. documents={documents}
  505. filteredDocuments={filteredDocuments}
  506. documentSearch={documentSearch}
  507. documentStatus={documentStatus}
  508. selectedDocumentId={selectedDocumentId}
  509. baseNameById={baseNameById}
  510. canAdd={Boolean(activeBaseIds.length)}
  511. onSearch={setDocumentSearch}
  512. onStatus={setDocumentStatus}
  513. onAdd={() => setDocumentOpen(true)}
  514. onSelect={(documentId) => {
  515. setSelectedDocumentId(documentId);
  516. setActiveTab("overview");
  517. }}
  518. />
  519. <DocumentInspector
  520. activeTab={activeTab}
  521. onTab={setActiveTab}
  522. selectedBase={selectedBase}
  523. selectedDocument={selectedDocument}
  524. selectedDocumentResults={selectedDocumentResults}
  525. selectedDocumentChunks={selectedDocumentChunks}
  526. lastIngest={lastIngest}
  527. />
  528. </div>
  529. ) : null}
  530. {section === "playground" ? (
  531. <div className="grid gap-6 2xl:grid-cols-[minmax(0,1fr)_420px]">
  532. <Card>
  533. <CardHeader>
  534. <CardTitle>{t("knowledge.retrievalPlayground")}</CardTitle>
  535. <p className="text-sm text-muted-foreground">
  536. {t("knowledge.askAgainstBase", { name: selectedBase?.name ?? t("knowledge.currentBase") })}
  537. </p>
  538. </CardHeader>
  539. <CardContent className="space-y-4">
  540. <form className="grid gap-3 xl:grid-cols-[minmax(0,1fr)_170px_140px] 2xl:grid-cols-[minmax(0,1fr)_170px_140px_auto]" onSubmit={(event) => void runSearch(event)}>
  541. <Input aria-label={t("knowledge.query")} value={query} onChange={(event) => setQuery(event.target.value)} placeholder={t("knowledge.askRetrievalQuestion")} />
  542. <Select aria-label={t("knowledge.searchSourceFilter")} value={sourceType} onChange={(event) => setSourceType(event.target.value)} options={sourceTypeOptions} />
  543. <Select aria-label="Top K" value={topK} onChange={(event) => setTopK(event.target.value)} options={[3, 5, 10, 20].map((value) => ({ value: String(value), label: `Top ${value}` }))} />
  544. <Button className="xl:col-span-3 2xl:col-span-1" disabled={!activeBaseIds.length || !query.trim() || searching}><Search className="h-4 w-4" /> {searching ? t("knowledge.searching") : t("knowledge.runSearch")}</Button>
  545. </form>
  546. {results.length ? <div className="space-y-3">{results.map((result) => <SearchResultCard key={result.chunk.id} result={result} />)}</div> : <EmptyState icon={Search} title={t("knowledge.noSearchResultsYet")} description={documents.length ? t("knowledge.runRetrievalInspect") : t("knowledge.addDocumentBeforeTest")} />}
  547. </CardContent>
  548. </Card>
  549. <RetrievalSettingsPanel config={retrievalConfig} models={models} onChange={updateRetrievalConfig} />
  550. </div>
  551. ) : null}
  552. {section === "evaluation" ? <EvaluationPage evalCases={evalCases} onAdd={addEvalCase} onRun={runEvalCase} /> : null}
  553. {section === "jobs" ? <JobsPage jobs={jobs} onCreateReindex={() => createJobsForActiveBases("Re-index")} /> : null}
  554. {section === "analytics" ? <AnalyticsPage documents={documents} results={results} jobs={jobs} evalCases={evalCases} /> : null}
  555. {section === "settings" ? (
  556. <div>
  557. <RetrievalSettingsPanel config={retrievalConfig} models={models} onChange={updateRetrievalConfig} />
  558. </div>
  559. ) : null}
  560. <CreateKnowledgeBaseDialog open={createOpen} onOpenChange={setCreateOpen} onCreated={() => void loadBases()} />
  561. <CreateKnowledgeDocumentDialog
  562. open={documentOpen}
  563. onOpenChange={setDocumentOpen}
  564. knowledgeBaseId={selectedBaseId ?? activeBaseIds[0]}
  565. knowledgeBaseName={baseNameById.get(selectedBaseId ?? activeBaseIds[0] ?? "")}
  566. onCreated={(ingest) => {
  567. setLastIngest(ingest);
  568. setChunksByDocument((current) => ({ ...current, [ingest.document.id]: ingest.chunks }));
  569. if (ingest.job) {
  570. setJobs((items) => [toKnowledgeJob(ingest.job!), ...items.filter((item) => item.id !== ingest.job?.jobId)]);
  571. }
  572. setSelectedDocumentId(ingest.document.id);
  573. setActiveTab("overview");
  574. void Promise.all([loadDocuments(activeBaseIds), loadJobs(activeBaseIds)]);
  575. }}
  576. />
  577. </div>
  578. );
  579. }
  580. function KnowledgeSectionNav() {
  581. const { t } = useTranslation();
  582. const workspaceSections = knowledgeSections.filter((item) => item.value !== "overview");
  583. return (
  584. <div className="overflow-x-auto rounded-md border border-border bg-surface-elevated p-1">
  585. <nav className="flex min-w-max gap-1">
  586. {workspaceSections.map((item) => (
  587. <NavLink
  588. key={item.value}
  589. to={item.path}
  590. className={({ isActive }) => [
  591. "inline-flex min-h-10 items-center gap-2 whitespace-nowrap rounded-sm px-3 text-sm text-muted-foreground transition hover:bg-muted hover:text-foreground focus:outline-none focus-visible:ring-2 focus-visible:ring-primary",
  592. isActive ? "bg-primary/15 text-primary" : "",
  593. ].join(" ")}
  594. >
  595. <item.icon className="h-4 w-4" />
  596. {t(`knowledge.sections.${item.value}`)}
  597. </NavLink>
  598. ))}
  599. </nav>
  600. </div>
  601. );
  602. }
  603. function KnowledgeScopeBar({
  604. bases,
  605. selectedBaseId,
  606. documentCount,
  607. indexedCount,
  608. jobCount,
  609. onSelectBase,
  610. onAddDocument,
  611. onTestSearch,
  612. onReindex,
  613. onSettings,
  614. }: {
  615. bases: KnowledgeBase[];
  616. selectedBaseId?: string;
  617. documentCount: number;
  618. indexedCount: number;
  619. jobCount: number;
  620. onSelectBase: (baseId: string) => void;
  621. onAddDocument: () => void;
  622. onTestSearch: () => void;
  623. onReindex: () => void;
  624. onSettings: () => void;
  625. }) {
  626. const { t } = useTranslation();
  627. const selectedBase = bases.find((base) => base.id === selectedBaseId);
  628. const hasBase = Boolean(selectedBase);
  629. return (
  630. <Card>
  631. <CardContent className="grid gap-4 p-4 2xl:grid-cols-[minmax(260px,360px)_minmax(0,1fr)_auto] 2xl:items-center">
  632. <div className="min-w-0">
  633. <p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">{t("knowledge.currentBase")}</p>
  634. <Select
  635. className="mt-2"
  636. value={selectedBaseId ?? ""}
  637. onChange={(event) => onSelectBase(event.target.value)}
  638. options={bases.map((base) => ({ value: base.id, label: demoText(base.name, t) }))}
  639. />
  640. </div>
  641. <div className="grid min-w-0 gap-2 sm:grid-cols-3">
  642. <ContextStat label={t("knowledge.documents")} value={documentCount} />
  643. <ContextStat label={t("knowledge.indexed")} value={indexedCount} />
  644. <ContextStat label={t("knowledge.jobs")} value={jobCount} />
  645. </div>
  646. <div className="flex min-w-0 flex-wrap gap-2 2xl:justify-end">
  647. <Button className="w-full sm:w-auto" size="sm" disabled={!hasBase} onClick={onAddDocument}>
  648. <FilePlus className="h-4 w-4" /> {t("knowledge.addDocument")}
  649. </Button>
  650. <Button className="w-full sm:w-auto" size="sm" variant="secondary" disabled={!hasBase} onClick={onTestSearch}>
  651. <Search className="h-4 w-4" /> {t("knowledge.testSearch")}
  652. </Button>
  653. <Button className="w-full sm:w-auto" size="sm" variant="secondary" disabled={!hasBase} onClick={onReindex}>
  654. <Layers3 className="h-4 w-4" /> {t("knowledge.reindex")}
  655. </Button>
  656. <Button className="w-11" size="icon" variant="ghost" disabled={!hasBase} aria-label={t("knowledge.openSettings")} onClick={onSettings}>
  657. <Settings2 className="h-4 w-4" />
  658. </Button>
  659. </div>
  660. </CardContent>
  661. </Card>
  662. );
  663. }
  664. function ContextStat({ label, value }: { label: string; value: number }) {
  665. return (
  666. <div className="min-w-0 rounded-md border border-border bg-muted/30 px-3 py-2">
  667. <p className="truncate text-xs text-muted-foreground">{label}</p>
  668. <p className="mt-1 font-mono text-sm font-semibold">{value}</p>
  669. </div>
  670. );
  671. }
  672. function KnowledgeBaseList({
  673. bases,
  674. baseSearch,
  675. selectedBaseId,
  676. onSearch,
  677. onSelect,
  678. onCreate,
  679. }: {
  680. bases: KnowledgeBase[];
  681. baseSearch: string;
  682. selectedBaseId?: string;
  683. onSearch: (value: string) => void;
  684. onSelect: (baseId: string) => void;
  685. onCreate: () => void;
  686. }) {
  687. const { t } = useTranslation();
  688. return (
  689. <Card>
  690. <CardHeader>
  691. <CardTitle>{t("knowledge.bases")}</CardTitle>
  692. </CardHeader>
  693. <CardContent className="space-y-4">
  694. <SearchInput value={baseSearch} onChange={onSearch} placeholder={t("knowledge.searchBases")} />
  695. {bases.length ? (
  696. <div className="space-y-2">
  697. {bases.map((base) => (
  698. <button
  699. key={base.id}
  700. type="button"
  701. onClick={() => onSelect(base.id)}
  702. className={[
  703. "flex min-h-16 w-full items-center justify-between gap-3 rounded-md border border-border bg-muted/40 p-3 text-left transition hover:bg-muted focus:outline-none focus-visible:ring-2 focus-visible:ring-primary",
  704. base.id === selectedBaseId ? "border-primary/40 bg-primary/10" : "",
  705. ].join(" ")}
  706. >
  707. <span className="min-w-0">
  708. <span className="block truncate text-sm font-medium">{demoText(base.name, t)}</span>
  709. {base.description ? <span className="mt-1 block truncate font-mono text-xs text-muted-foreground">{demoText(base.description, t)}</span> : null}
  710. </span>
  711. <span className="shrink-0"><StatusBadge status={base.status} /></span>
  712. </button>
  713. ))}
  714. </div>
  715. ) : (
  716. <EmptyState icon={BookOpen} title={t("knowledge.noBasesFound")} description={t("knowledge.createOrSearch")} actionLabel={t("knowledge.newBase")} onAction={onCreate} />
  717. )}
  718. </CardContent>
  719. </Card>
  720. );
  721. }
  722. function BaseSummaryCard({
  723. selectedBase,
  724. selectedBases,
  725. documents,
  726. indexedCount,
  727. statusBusy,
  728. onToggleStatus,
  729. onOpenBase,
  730. }: {
  731. selectedBase?: KnowledgeBase;
  732. selectedBases: KnowledgeBase[];
  733. documents: KnowledgeDocument[];
  734. indexedCount: number;
  735. statusBusy: boolean;
  736. onToggleStatus: () => void;
  737. onOpenBase: () => void;
  738. }) {
  739. const { t } = useTranslation();
  740. const title = selectedBases.length > 1
  741. ? t("knowledge.knowledgeBases_plural", { count: selectedBases.length })
  742. : demoText(selectedBase?.name, t) || t("knowledge.selectKnowledgeBase");
  743. return (
  744. <Card>
  745. <CardHeader>
  746. <div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
  747. <div className="min-w-0">
  748. <CardTitle>{title}</CardTitle>
  749. <p className="mt-1 break-words text-sm text-muted-foreground">
  750. {selectedBases.length > 1 ? selectedBases.map((base) => demoText(base.name, t)).join(", ") : demoText(selectedBase?.description, t) || t("knowledge.chooseBaseManage")}
  751. </p>
  752. </div>
  753. <div className="flex flex-wrap gap-2 lg:justify-end">
  754. <Button className="w-full sm:w-auto" disabled={!selectedBase} onClick={onOpenBase}>
  755. <FileText className="h-4 w-4" /> {t("knowledge.openBase")}
  756. </Button>
  757. {selectedBase ? (
  758. <Button className="w-full sm:w-auto" variant="outline" disabled={statusBusy} onClick={onToggleStatus}>
  759. <Archive className="h-4 w-4" /> {selectedBase.status === "active" ? t("knowledge.archive") : t("knowledge.restoreCurrent")}
  760. </Button>
  761. ) : null}
  762. </div>
  763. </div>
  764. </CardHeader>
  765. <CardContent className="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
  766. <Detail label={t("knowledge.selected")} value={String(selectedBases.length || (selectedBase ? 1 : 0))} />
  767. <Detail label={t("common.status")} value={selectedBase ? t(`status.${selectedBase.status}`) : t("knowledge.notSelected")} />
  768. <Detail label={t("knowledge.documents")} value={String(documents.length)} />
  769. <Detail label={t("knowledge.indexed")} value={String(indexedCount)} />
  770. </CardContent>
  771. </Card>
  772. );
  773. }
  774. function DocumentsPanel({
  775. documentsLoading,
  776. documents,
  777. filteredDocuments,
  778. documentSearch,
  779. documentStatus,
  780. selectedDocumentId,
  781. baseNameById,
  782. canAdd,
  783. onSearch,
  784. onStatus,
  785. onAdd,
  786. onSelect,
  787. }: {
  788. documentsLoading: boolean;
  789. documents: KnowledgeDocument[];
  790. filteredDocuments: KnowledgeDocument[];
  791. documentSearch: string;
  792. documentStatus: string;
  793. selectedDocumentId?: string;
  794. baseNameById: Map<string, string>;
  795. canAdd: boolean;
  796. onSearch: (value: string) => void;
  797. onStatus: (value: string) => void;
  798. onAdd: () => void;
  799. onSelect: (documentId: string) => void;
  800. }) {
  801. const { t } = useTranslation();
  802. return (
  803. <Card>
  804. <CardHeader>
  805. <div className="flex flex-col gap-4 xl:flex-row xl:items-start xl:justify-between">
  806. <div className="min-w-0">
  807. <CardTitle>{t("knowledge.documents")}</CardTitle>
  808. <p className="mt-1 text-sm text-muted-foreground">{t("knowledge.documentsHint")}</p>
  809. </div>
  810. <div className="grid w-full min-w-0 gap-3 md:grid-cols-[minmax(0,1fr)_180px] 2xl:w-[720px] 2xl:grid-cols-[minmax(0,1fr)_180px_auto]">
  811. <SearchInput className="w-full sm:w-full" value={documentSearch} onChange={onSearch} placeholder={t("knowledge.searchDocuments")} />
  812. <Select aria-label={t("common.status")} value={documentStatus} onChange={(event) => onStatus(event.target.value)} options={documentStatusValues.map((value) => ({ value, label: t(`knowledge.statusLabels.${value}`) }))} />
  813. <Button className="md:col-span-2 2xl:col-span-1" variant="secondary" disabled={!canAdd} onClick={onAdd}><FilePlus className="h-4 w-4" /> {t("knowledge.addDocument")}</Button>
  814. </div>
  815. </div>
  816. </CardHeader>
  817. <CardContent>
  818. {documentsLoading ? (
  819. <LoadingSpinner label={t("knowledge.loadingDocuments")} />
  820. ) : filteredDocuments.length ? (
  821. <div className="grid gap-3 xl:grid-cols-2">
  822. {filteredDocuments.map((document) => (
  823. <button
  824. key={document.id}
  825. type="button"
  826. onClick={() => onSelect(document.id)}
  827. className={[
  828. "min-h-28 rounded-md border p-4 text-left transition hover:bg-muted focus:outline-none focus-visible:ring-2 focus-visible:ring-primary",
  829. document.id === selectedDocumentId ? "border-primary/50 bg-primary/10" : "border-border bg-muted/30",
  830. ].join(" ")}
  831. >
  832. <div className="flex items-start justify-between gap-3">
  833. <div className="min-w-0">
  834. <p className="truncate text-sm font-semibold">{demoText(document.title, t)}</p>
  835. <p className="mt-1 truncate text-xs text-muted-foreground">
  836. {baseNameById.get(document.knowledgeBaseId) ?? t("knowledge.title")} / {document.sourceUri || t(`knowledge.sourceLabels.${document.sourceType}`, humanizeCode(document.sourceType))}
  837. </p>
  838. </div>
  839. <span className="shrink-0"><StatusBadge status={document.status} /></span>
  840. </div>
  841. <div className="mt-4 grid gap-3 text-xs text-muted-foreground sm:grid-cols-2">
  842. <Detail label={t("knowledge.source")} value={t(`knowledge.sourceLabels.${document.sourceType}`, humanizeCode(document.sourceType))} />
  843. <Detail label={t("knowledge.indexed")} value={document.indexedTime ? formatDateTime(document.indexedTime) : t("knowledge.pending")} />
  844. </div>
  845. <p className="mt-4 text-xs font-medium text-primary">{t("knowledge.openDetails")}</p>
  846. </button>
  847. ))}
  848. </div>
  849. ) : (
  850. <EmptyState
  851. icon={FileText}
  852. title={documents.length ? t("knowledge.noMatchingDocuments") : t("knowledge.noDocuments")}
  853. description={documents.length ? t("knowledge.adjustSearchOrStatus") : canAdd ? t("knowledge.addTextMarkdownJson") : t("knowledge.selectKnowledgeBaseFirst")}
  854. actionLabel={canAdd ? t("knowledge.addDocument") : undefined}
  855. onAction={canAdd ? onAdd : undefined}
  856. />
  857. )}
  858. </CardContent>
  859. </Card>
  860. );
  861. }
  862. function DocumentInspector({
  863. activeTab,
  864. onTab,
  865. selectedBase,
  866. selectedDocument,
  867. selectedDocumentResults,
  868. selectedDocumentChunks,
  869. lastIngest,
  870. }: {
  871. activeTab: string;
  872. onTab: (value: string) => void;
  873. selectedBase?: KnowledgeBase;
  874. selectedDocument?: KnowledgeDocument;
  875. selectedDocumentResults: SearchResult[];
  876. selectedDocumentChunks: KnowledgeChunk[];
  877. lastIngest?: KnowledgeDocumentIngestResponse;
  878. }) {
  879. const { t } = useTranslation();
  880. return (
  881. <Card className="min-w-0">
  882. <CardHeader>
  883. <CardTitle className="break-words">{selectedDocument ? demoText(selectedDocument.title, t) : t("knowledge.inspector")}</CardTitle>
  884. </CardHeader>
  885. <CardContent className="min-w-0">
  886. <Tabs
  887. value={activeTab}
  888. onChange={onTab}
  889. tabs={[
  890. {
  891. value: "overview",
  892. label: t("common.overview"),
  893. content: selectedDocument ? (
  894. <div className="space-y-4 text-sm">
  895. <div className="grid gap-3 sm:grid-cols-2">
  896. <Detail label={t("common.status")} value={t(`status.${selectedDocument.status}`, humanizeCode(selectedDocument.status))} />
  897. <Detail label={t("knowledge.source")} value={t(`knowledge.sourceLabels.${selectedDocument.sourceType}`, humanizeCode(selectedDocument.sourceType))} />
  898. <Detail label={t("common.created")} value={formatDateTime(selectedDocument.createdTime)} />
  899. <Detail label={t("knowledge.indexed")} value={selectedDocument.indexedTime ? formatDateTime(selectedDocument.indexedTime) : t("knowledge.pending")} />
  900. </div>
  901. <Detail label={t("knowledge.sourceUri")} value={selectedDocument.sourceUri ?? t("knowledge.notProvided")} />
  902. <Detail label={t("knowledge.contentHash")} value={selectedDocument.contentHash ?? t("knowledge.notAvailable")} />
  903. {lastIngest?.document.id === selectedDocument.id ? (
  904. <div className="rounded-md border border-green-500/25 bg-green-500/10 p-3 text-sm text-green-700 dark:text-green-200">
  905. {t("knowledge.lastIngestCreated", { count: lastIngest.chunks.length })}
  906. </div>
  907. ) : null}
  908. </div>
  909. ) : (
  910. <EmptyState icon={FileText} title={t("knowledge.selectDocument")} description={t("knowledge.documentDetailsAppear")} />
  911. ),
  912. },
  913. {
  914. value: "search",
  915. label: t("knowledge.search"),
  916. content: selectedDocumentResults.length ? (
  917. <div className="space-y-3">{selectedDocumentResults.map((result) => <SearchResultCard key={result.chunk.id} result={result} />)}</div>
  918. ) : (
  919. <EmptyState icon={Search} title={t("knowledge.noDocumentSearchResults")} description={t("knowledge.runQueryPlayground")} />
  920. ),
  921. },
  922. {
  923. value: "metadata",
  924. label: t("knowledge.propertiesTab"),
  925. content: <MetadataSummary metadata={selectedDocument?.metadata ?? selectedBase?.metadata ?? {}} />,
  926. },
  927. {
  928. value: "chunks",
  929. label: t("knowledge.chunks"),
  930. content: selectedDocumentChunks.length ? (
  931. <div className="space-y-3">
  932. {selectedDocumentChunks.map((chunk) => (
  933. <div key={chunk.id} className="rounded-md border border-border bg-muted/30 p-3">
  934. <div className="flex flex-wrap items-center justify-between gap-3">
  935. <span className="font-mono text-xs text-muted-foreground">{t("knowledge.chunk")} #{chunk.chunkIndex}</span>
  936. <span className="font-mono text-xs text-muted-foreground">{chunk.tokenCount} {t("knowledge.tokens")}</span>
  937. </div>
  938. <p className="mt-3 text-sm leading-6 text-muted-foreground">{chunk.contentText}</p>
  939. </div>
  940. ))}
  941. </div>
  942. ) : (
  943. <EmptyState icon={Layers3} title={t("knowledge.noChunksLoaded")} description={t("knowledge.runSearchOrIndex")} />
  944. ),
  945. },
  946. ]}
  947. />
  948. </CardContent>
  949. </Card>
  950. );
  951. }
  952. function EvaluationPage({ evalCases, onAdd, onRun }: { evalCases: EvalCase[]; onAdd: (queryText: string, expected: string) => void; onRun: (evalId: string) => void }) {
  953. const { t } = useTranslation();
  954. const [queryText, setQueryText] = React.useState("");
  955. const [expected, setExpected] = React.useState("");
  956. const avgRecall = average(evalCases.map((item) => item.recall));
  957. const avgPrecision = average(evalCases.map((item) => item.precision));
  958. function submit(event: React.FormEvent) {
  959. event.preventDefault();
  960. if (!queryText.trim()) return;
  961. onAdd(queryText.trim(), expected.trim() || t("knowledge.expectedCitation"));
  962. setQueryText("");
  963. setExpected("");
  964. }
  965. return (
  966. <div className="grid gap-6 2xl:grid-cols-[420px_minmax(0,1fr)]">
  967. <Card>
  968. <CardHeader>
  969. <CardTitle>{t("knowledge.goldenQuery")}</CardTitle>
  970. <p className="text-sm text-muted-foreground">{t("knowledge.buildEvaluationSet")}</p>
  971. </CardHeader>
  972. <CardContent>
  973. <form className="space-y-4" onSubmit={submit}>
  974. <Field label={t("knowledge.query")}><Textarea required value={queryText} onChange={(event) => setQueryText(event.target.value)} /></Field>
  975. <Field label={t("knowledge.expectedSource")}><Input value={expected} onChange={(event) => setExpected(event.target.value)} /></Field>
  976. <Button className="w-full"><ClipboardCheck className="h-4 w-4" /> {t("knowledge.addCase")}</Button>
  977. </form>
  978. <div className="mt-6 grid grid-cols-2 gap-3">
  979. <ScoreTile label={t("knowledge.avgRecall")} value={avgRecall} />
  980. <ScoreTile label={t("knowledge.avgPrecision")} value={avgPrecision} />
  981. </div>
  982. </CardContent>
  983. </Card>
  984. <Card>
  985. <CardHeader>
  986. <CardTitle>{t("knowledge.evaluationCases")}</CardTitle>
  987. </CardHeader>
  988. <CardContent className="space-y-3">
  989. {evalCases.map((item) => (
  990. <div key={item.id} className="rounded-md border border-border bg-muted/30 p-4">
  991. <div className="flex items-start justify-between gap-3">
  992. <div className="min-w-0">
  993. <p className="break-words text-sm font-medium">{formatEvalText(item.query, t)}</p>
  994. <p className="mt-1 break-words text-xs text-muted-foreground">{t("knowledge.expected")}: {formatEvalText(item.expected, t)}</p>
  995. </div>
  996. <span className="shrink-0"><StatusBadge status={item.status} /></span>
  997. </div>
  998. <div className="mt-4 grid gap-3 xl:grid-cols-[minmax(0,1fr)_minmax(0,1fr)_auto]">
  999. <BarMeter label={t("knowledge.recall")} value={item.recall} />
  1000. <BarMeter label={t("knowledge.precision")} value={item.precision} />
  1001. <Button className="w-full xl:w-auto" size="sm" variant="secondary" onClick={() => onRun(item.id)}><RotateCcw className="h-4 w-4" /> {t("common.run")}</Button>
  1002. </div>
  1003. </div>
  1004. ))}
  1005. </CardContent>
  1006. </Card>
  1007. </div>
  1008. );
  1009. }
  1010. function JobsPage({ jobs, onCreateReindex }: { jobs: KnowledgeJob[]; onCreateReindex: () => void }) {
  1011. const { t } = useTranslation();
  1012. return (
  1013. <Card>
  1014. <CardHeader>
  1015. <div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
  1016. <div>
  1017. <CardTitle>{t("knowledge.indexJobs")}</CardTitle>
  1018. <p className="text-sm text-muted-foreground">{t("knowledge.manageFrontendQueues")}</p>
  1019. </div>
  1020. <Button className="w-full sm:w-auto" onClick={onCreateReindex}><RefreshCw className="h-4 w-4" /> {t("knowledge.reindexBase")}</Button>
  1021. </div>
  1022. </CardHeader>
  1023. <CardContent className="space-y-3">
  1024. {jobs.map((job) => (
  1025. <div key={job.id} className="rounded-md border border-border bg-muted/30 p-4">
  1026. <div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
  1027. <div className="min-w-0">
  1028. <div className="flex flex-wrap items-center gap-2">
  1029. <p className="break-words text-sm font-semibold">{formatKnowledgeJobType(job.type, t)}</p>
  1030. <StatusBadge status={job.status} />
  1031. </div>
  1032. <p className="mt-1 break-words text-xs text-muted-foreground">{formatKnowledgeJobTarget(job.target, t)} / {formatDateTime(job.createdTime)}</p>
  1033. </div>
  1034. </div>
  1035. <div className="mt-4">
  1036. <BarMeter label={t("knowledge.progress")} value={job.progress / 100} />
  1037. </div>
  1038. </div>
  1039. ))}
  1040. </CardContent>
  1041. </Card>
  1042. );
  1043. }
  1044. function AnalyticsPage({ documents, results, jobs, evalCases }: { documents: KnowledgeDocument[]; results: SearchResult[]; jobs: KnowledgeJob[]; evalCases: EvalCase[] }) {
  1045. const { t } = useTranslation();
  1046. const failedJobs = jobs.filter((job) => job.status === "failed").length;
  1047. const avgScore = average(results.map((result) => result.score));
  1048. const indexedRatio = documents.length ? documents.filter((document) => document.status === "indexed").length / documents.length : 0;
  1049. const passRatio = evalCases.length ? evalCases.filter((item) => item.status === "passed").length / evalCases.length : 0;
  1050. return (
  1051. <div className="space-y-6">
  1052. <div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
  1053. <MetricCard label={t("knowledge.indexedRatio")} value={`${Math.round(indexedRatio * 100)}%`} icon={Sparkles} />
  1054. <MetricCard label={t("knowledge.avgScore")} value={avgScore ? avgScore.toFixed(2) : "0.00"} icon={Gauge} />
  1055. <MetricCard label={t("knowledge.evalPass")} value={`${Math.round(passRatio * 100)}%`} icon={ClipboardCheck} />
  1056. <MetricCard label={t("knowledge.failedJobs")} value={failedJobs} icon={BarChart3} />
  1057. </div>
  1058. <Card>
  1059. <CardHeader>
  1060. <CardTitle>{t("knowledge.qualitySignals")}</CardTitle>
  1061. </CardHeader>
  1062. <CardContent className="grid gap-4 md:grid-cols-2">
  1063. <BarMeter label={t("knowledge.indexCoverage")} value={indexedRatio} />
  1064. <BarMeter label={t("knowledge.citationConfidence")} value={avgScore || 0} />
  1065. <BarMeter label={t("knowledge.evaluationPassRate")} value={passRatio} />
  1066. <BarMeter label={t("knowledge.jobHealth")} value={jobs.length ? 1 - failedJobs / jobs.length : 1} />
  1067. </CardContent>
  1068. </Card>
  1069. <Card>
  1070. <CardHeader>
  1071. <CardTitle>{t("knowledge.topQueries")}</CardTitle>
  1072. </CardHeader>
  1073. <CardContent className="grid gap-3 md:grid-cols-2">
  1074. {["downloadInvoice", "refundPolicy", "billingContact", "planUpgrade"].map((item, index) => (
  1075. <div key={item} className="rounded-md border border-border bg-muted/30 p-4">
  1076. <p className="text-sm font-medium">{t(`knowledge.topQuery.${item}`)}</p>
  1077. <p className="mt-1 font-mono text-xs text-muted-foreground">
  1078. {t("knowledge.queryStats", { count: 42 - index * 7, rate: Math.round((0.91 - index * 0.06) * 100) })}
  1079. </p>
  1080. </div>
  1081. ))}
  1082. </CardContent>
  1083. </Card>
  1084. </div>
  1085. );
  1086. }
  1087. function ToggleRow({ icon: Icon, title, description, checked, onChange }: { icon: LucideIcon; title: string; description: string; checked: boolean; onChange: (checked: boolean) => void }) {
  1088. return (
  1089. <label className="flex min-h-16 cursor-pointer items-start justify-between gap-4 rounded-md border border-border bg-muted/20 p-3">
  1090. <span className="flex min-w-0 gap-3">
  1091. <Icon className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" />
  1092. <span className="min-w-0">
  1093. <span className="block text-sm font-medium">{title}</span>
  1094. <span className="mt-1 block text-xs leading-5 text-muted-foreground">{description}</span>
  1095. </span>
  1096. </span>
  1097. <input type="checkbox" className="mt-1 h-5 w-5 shrink-0 accent-primary" checked={checked} onChange={(event) => onChange(event.target.checked)} />
  1098. </label>
  1099. );
  1100. }
  1101. function BarMeter({ label, value }: { label: string; value: number }) {
  1102. const normalized = Math.max(0, Math.min(1, value || 0));
  1103. return (
  1104. <div>
  1105. <div className="flex items-center justify-between gap-3">
  1106. <p className="text-xs text-muted-foreground">{label}</p>
  1107. <p className="font-mono text-xs text-foreground">{Math.round(normalized * 100)}%</p>
  1108. </div>
  1109. <div className="mt-2 h-2 overflow-hidden rounded-full bg-muted">
  1110. <div className="h-full rounded-full bg-primary" style={{ width: `${normalized * 100}%` }} />
  1111. </div>
  1112. </div>
  1113. );
  1114. }
  1115. function ScoreTile({ label, value }: { label: string; value: number }) {
  1116. return (
  1117. <div className="rounded-md border border-border bg-muted/30 p-3">
  1118. <p className="text-xs text-muted-foreground">{label}</p>
  1119. <p className="mt-1 font-mono text-xl font-semibold">{Math.round(value * 100)}%</p>
  1120. </div>
  1121. );
  1122. }
  1123. function average(values: number[]) {
  1124. const useful = values.filter((value) => value > 0);
  1125. return useful.length ? useful.reduce((sum, value) => sum + value, 0) / useful.length : 0;
  1126. }
  1127. function groupChunksByDocument(chunks: KnowledgeChunk[]) {
  1128. return chunks.reduce<Record<string, KnowledgeChunk[]>>((groups, chunk) => {
  1129. groups[chunk.documentId] = [...(groups[chunk.documentId] ?? []), chunk].sort((a, b) => a.chunkIndex - b.chunkIndex);
  1130. return groups;
  1131. }, {});
  1132. }
  1133. function KnowledgeCapabilityBoard({ compact = false }: { compact?: boolean }) {
  1134. const { t } = useTranslation();
  1135. return (
  1136. <Card>
  1137. <CardHeader>
  1138. <div className="flex flex-col gap-2 lg:flex-row lg:items-center lg:justify-between">
  1139. <div>
  1140. <CardTitle>{t("knowledge.knowledgeCapabilityMap")}</CardTitle>
  1141. <p className="mt-1 text-sm text-muted-foreground">
  1142. {compact ? t("knowledge.moduleStatusAtGlance") : t("knowledge.fullRagSurface")}
  1143. </p>
  1144. </div>
  1145. <div className="flex flex-wrap gap-2">
  1146. <StateBadge state="live" />
  1147. <StateBadge state="mock" />
  1148. <StateBadge state="prototype" />
  1149. </div>
  1150. </div>
  1151. </CardHeader>
  1152. <CardContent>
  1153. <div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
  1154. {capabilityGroups.map((group) => (
  1155. <div key={group.titleKey} className="rounded-md border border-border bg-muted/30 p-4">
  1156. <div className="flex items-center gap-3">
  1157. <span className="flex h-10 w-10 items-center justify-center rounded-md bg-primary/10 text-primary">
  1158. <group.icon className="h-5 w-5" />
  1159. </span>
  1160. <h3 className="text-sm font-semibold">{t(group.titleKey)}</h3>
  1161. </div>
  1162. <div className="mt-4 space-y-2">
  1163. {group.items.map((item) => (
  1164. <div key={item.labelKey} className="flex items-start justify-between gap-3 text-sm">
  1165. <span className="min-w-0 break-words text-muted-foreground">{t(item.labelKey)}</span>
  1166. <StateBadge state={item.state} compact />
  1167. </div>
  1168. ))}
  1169. </div>
  1170. </div>
  1171. ))}
  1172. </div>
  1173. </CardContent>
  1174. </Card>
  1175. );
  1176. }
  1177. function RetrievalSettingsPanel({
  1178. config,
  1179. models,
  1180. onChange,
  1181. }: {
  1182. config: RetrievalConfig;
  1183. models: ModelDefinition[];
  1184. onChange: (config: RetrievalConfig) => void;
  1185. }) {
  1186. const { t } = useTranslation();
  1187. const embeddingOptions = buildModelOptions(models, t("knowledge.autoSelect"), "embedding", t);
  1188. const rerankOptions = buildModelOptions(models, t("knowledge.autoSelect"), "rerank", t);
  1189. function update(patch: Partial<RetrievalConfig>) {
  1190. onChange({ ...config, ...patch });
  1191. }
  1192. return (
  1193. <Card>
  1194. <CardHeader>
  1195. <CardTitle>{t("knowledge.retrievalSettings")}</CardTitle>
  1196. <p className="text-sm text-muted-foreground">{t("knowledge.tuneRetrievalBehavior")}</p>
  1197. </CardHeader>
  1198. <CardContent className="space-y-5">
  1199. <div className="rounded-md border border-border bg-muted/30 p-4">
  1200. <div className="flex items-center gap-2">
  1201. <Settings2 className="h-4 w-4 text-primary" />
  1202. <p className="text-sm font-semibold">{t("knowledge.modelSelection")}</p>
  1203. </div>
  1204. <div className="mt-4 grid gap-3 lg:grid-cols-3">
  1205. <Field label={t("knowledge.retrievalMode")}>
  1206. <Select value={config.retrievalMode} onChange={(event) => update({ retrievalMode: event.target.value })} options={[
  1207. { value: "hybrid", label: t("knowledge.hybridRetrieval") },
  1208. { value: "vector", label: t("knowledge.vectorOnly") },
  1209. { value: "keyword", label: t("knowledge.keywordOnly") },
  1210. ]} />
  1211. </Field>
  1212. <Field label={t("knowledge.embeddingModel")}>
  1213. <Select value={normalizeSelectedModel(config.embeddingModelId, embeddingOptions)} onChange={(event) => update({ embeddingModelId: event.target.value })} options={embeddingOptions} />
  1214. </Field>
  1215. <Field label={t("knowledge.rerankModel")}>
  1216. <Select value={normalizeSelectedModel(config.rerankModelId, rerankOptions)} onChange={(event) => update({ rerankModelId: event.target.value })} options={rerankOptions} />
  1217. </Field>
  1218. </div>
  1219. {!models.length ? (
  1220. <p className="mt-3 rounded-md border border-amber-500/25 bg-amber-500/10 p-3 text-xs text-amber-700 dark:text-amber-200">
  1221. {t("knowledge.noConfiguredModels")}
  1222. </p>
  1223. ) : null}
  1224. </div>
  1225. <div className="rounded-md border border-border bg-muted/30 p-4">
  1226. <div className="flex items-center gap-2">
  1227. <FileSearch className="h-4 w-4 text-primary" />
  1228. <p className="text-sm font-semibold">{t("knowledge.retrievalDefaults")}</p>
  1229. </div>
  1230. <div className="mt-4 grid gap-3 md:grid-cols-2 2xl:grid-cols-5">
  1231. <Field label={t("knowledge.chunkSize")}><Input inputMode="numeric" value={config.chunkSize} onChange={(event) => update({ chunkSize: event.target.value })} /></Field>
  1232. <Field label={t("knowledge.chunkOverlap")}><Input inputMode="numeric" value={config.chunkOverlap} onChange={(event) => update({ chunkOverlap: event.target.value })} /></Field>
  1233. <Field label={t("knowledge.topK")}><Input inputMode="numeric" value={config.topK} onChange={(event) => update({ topK: event.target.value })} /></Field>
  1234. <Field label={t("knowledge.candidatePool")}><Input inputMode="numeric" value={config.maxCandidates} onChange={(event) => update({ maxCandidates: event.target.value })} /></Field>
  1235. <Field label={t("knowledge.minimumScore")}><Input inputMode="decimal" value={config.minScore} onChange={(event) => update({ minScore: event.target.value })} /></Field>
  1236. </div>
  1237. </div>
  1238. <div className="grid gap-3 lg:grid-cols-3">
  1239. <Field label={t("knowledge.keywordWeight")}><Input value={config.keywordWeight} onChange={(event) => update({ keywordWeight: event.target.value })} /></Field>
  1240. <Field label={t("knowledge.vectorWeight")}><Input value={config.vectorWeight} onChange={(event) => update({ vectorWeight: event.target.value })} /></Field>
  1241. <Field label={t("knowledge.rerankWeight")}><Input value={config.rerankWeight} onChange={(event) => update({ rerankWeight: event.target.value })} /></Field>
  1242. </div>
  1243. <div className="grid gap-3">
  1244. <ToggleRow icon={SlidersHorizontal} title={t("knowledge.queryRewrite")} description={t("knowledge.expandShortQueries")} checked={config.queryRewrite} onChange={(checked) => update({ queryRewrite: checked })} />
  1245. <ToggleRow icon={FileSearch} title={t("knowledge.requireCitations")} description={t("knowledge.treatUncitedAnswers")} checked={config.requireCitations} onChange={(checked) => update({ requireCitations: checked })} />
  1246. </div>
  1247. </CardContent>
  1248. </Card>
  1249. );
  1250. }
  1251. function buildModelOptions(models: ModelDefinition[], autoLabel: string, capability: "embedding" | "rerank" | undefined, t: ReturnType<typeof useTranslation>["t"]) {
  1252. const matchedModels = capability ? models.filter((model) => modelMatchesCapability(model, capability)) : models;
  1253. const usableModels = matchedModels.length ? matchedModels : models;
  1254. return [
  1255. { value: "auto", label: autoLabel },
  1256. ...usableModels.map((model) => ({
  1257. value: model.id,
  1258. label: `${demoText(model.name, t)} (${model.model_name})`,
  1259. })),
  1260. ];
  1261. }
  1262. function modelMatchesCapability(model: ModelDefinition, capability: "embedding" | "rerank") {
  1263. const text = `${model.name} ${model.model_name} ${model.description ?? ""} ${model.capabilities_json.join(" ")}`.toLowerCase();
  1264. if (capability === "embedding") {
  1265. return text.includes("embed") || text.includes("vector");
  1266. }
  1267. return text.includes("rerank") || text.includes("ranker") || text.includes("re-rank");
  1268. }
  1269. function normalizeSelectedModel(value: string, options: Array<{ value: string; label: string }>) {
  1270. return options.some((option) => option.value === value) ? value : "auto";
  1271. }
  1272. function settingsPayloadToForm(payload: KnowledgeSettingsPayload): RetrievalConfig {
  1273. return {
  1274. retrievalMode: payload.retrievalMode,
  1275. embeddingModelId: payload.embeddingModelId,
  1276. rerankModelId: payload.rerankModelId,
  1277. chunkSize: String(payload.chunkSize),
  1278. chunkOverlap: String(payload.chunkOverlap),
  1279. topK: String(payload.topK),
  1280. minScore: String(payload.minScore),
  1281. maxCandidates: String(payload.maxCandidates),
  1282. keywordWeight: String(payload.keywordWeight),
  1283. vectorWeight: String(payload.vectorWeight),
  1284. rerankWeight: String(payload.rerankWeight),
  1285. queryRewrite: payload.queryRewrite,
  1286. requireCitations: payload.requireCitations,
  1287. };
  1288. }
  1289. function formToSettingsPayload(config: RetrievalConfig, knowledgeBaseId: string): KnowledgeSettingsPayload {
  1290. return {
  1291. knowledgeBaseId,
  1292. retrievalMode: config.retrievalMode,
  1293. embeddingModelId: config.embeddingModelId,
  1294. rerankModelId: config.rerankModelId,
  1295. chunkSize: readInteger(config.chunkSize, 800),
  1296. chunkOverlap: readInteger(config.chunkOverlap, 120),
  1297. topK: readInteger(config.topK, 5),
  1298. minScore: readNumber(config.minScore, 0),
  1299. maxCandidates: readInteger(config.maxCandidates, 50),
  1300. keywordWeight: readNumber(config.keywordWeight, 0.55),
  1301. vectorWeight: readNumber(config.vectorWeight, 0.3),
  1302. rerankWeight: readNumber(config.rerankWeight, 0.15),
  1303. queryRewrite: config.queryRewrite,
  1304. requireCitations: config.requireCitations,
  1305. };
  1306. }
  1307. function readInteger(value: string, fallback: number) {
  1308. const parsed = Number(value);
  1309. return Number.isFinite(parsed) ? Math.max(0, Math.round(parsed)) : fallback;
  1310. }
  1311. function readNumber(value: string, fallback: number) {
  1312. const parsed = Number(value);
  1313. return Number.isFinite(parsed) ? parsed : fallback;
  1314. }
  1315. function StateBadge({ state, compact }: { state: string; compact?: boolean }) {
  1316. const { t } = useTranslation();
  1317. const styles = {
  1318. live: "border-green-500/30 bg-green-500/10 text-green-700 dark:text-green-200",
  1319. mock: "border-blue-500/30 bg-blue-500/10 text-blue-700 dark:text-blue-200",
  1320. prototype: "border-blue-500/30 bg-blue-500/10 text-blue-700 dark:text-blue-200",
  1321. } as const;
  1322. const labels: Record<string, string> = {
  1323. live: t("knowledge.state.live"),
  1324. mock: compact ? t("knowledge.state.mock") : t("knowledge.state.mock"),
  1325. prototype: compact ? t("knowledge.state.proto") : t("knowledge.state.prototype"),
  1326. };
  1327. const key = state as keyof typeof styles;
  1328. return <Badge className={`${styles[key] ?? styles.prototype} shrink-0 whitespace-nowrap`}>{labels[state] ?? state}</Badge>;
  1329. }
  1330. function SearchResultCard({ result }: { result: SearchResult }) {
  1331. const { t } = useTranslation();
  1332. return (
  1333. <div className="rounded-md border border-border bg-muted/30 p-3">
  1334. <div className="flex items-start justify-between gap-3">
  1335. <div className="min-w-0">
  1336. <p className="truncate text-sm font-semibold">{demoText(result.document.title, t)}</p>
  1337. <p className="mt-1 text-xs text-muted-foreground">{t("knowledge.chunk")} {result.chunk.chunkIndex} / {result.chunk.tokenCount} {t("knowledge.tokens")}</p>
  1338. </div>
  1339. <span className="shrink-0 rounded-md bg-primary/10 px-2 py-1 font-mono text-xs text-primary">{result.score.toFixed(3)}</span>
  1340. </div>
  1341. <p className="mt-3 break-words text-sm leading-6 text-muted-foreground">{result.chunk.contentText}</p>
  1342. <ScoreBreakdown score={result.score} details={result.scoreDetails} />
  1343. </div>
  1344. );
  1345. }
  1346. function ScoreBreakdown({ score, details }: { score: number; details?: JSONObject }) {
  1347. const { t } = useTranslation();
  1348. const entries = getReadableEntries(details).slice(0, 4);
  1349. return (
  1350. <div className="mt-3 rounded-md border border-border bg-surface-elevated p-3">
  1351. <div className="flex items-center justify-between gap-3">
  1352. <p className="text-xs font-medium text-muted-foreground">{t("knowledge.scoreDetails")}</p>
  1353. <span className="shrink-0 rounded-sm bg-primary/10 px-2 py-1 font-mono text-xs text-primary">{Math.round(score * 100)}%</span>
  1354. </div>
  1355. {entries.length ? (
  1356. <div className="mt-3 grid gap-2 sm:grid-cols-2">
  1357. {entries.map(([key, value]) => (
  1358. <Detail key={key} label={formatPropertyLabel(key, t)} value={formatPropertyValue(value, t)} />
  1359. ))}
  1360. </div>
  1361. ) : (
  1362. <p className="mt-2 text-xs text-muted-foreground">{t("knowledge.noExtraScoringSignals")}</p>
  1363. )}
  1364. </div>
  1365. );
  1366. }
  1367. function MetadataSummary({ metadata }: { metadata?: JSONObject | null }) {
  1368. const { t } = useTranslation();
  1369. const entries = getReadableEntries(metadata);
  1370. if (!entries.length) {
  1371. return <EmptyState icon={FileSearch} title={t("knowledge.noProperties")} description={t("knowledge.noPropertiesDescription")} />;
  1372. }
  1373. return (
  1374. <div className="grid gap-3 sm:grid-cols-2">
  1375. {entries.map(([key, value]) => (
  1376. <div key={key} className="rounded-md border border-border bg-muted/30 p-3">
  1377. <p className="text-xs text-muted-foreground">{formatPropertyLabel(key, t)}</p>
  1378. <p className="mt-1 break-words text-sm text-foreground">{formatPropertyValue(value, t)}</p>
  1379. </div>
  1380. ))}
  1381. </div>
  1382. );
  1383. }
  1384. function CreateKnowledgeBaseDialog({ open, onOpenChange, onCreated }: { open: boolean; onOpenChange: (open: boolean) => void; onCreated: () => void }) {
  1385. const { t } = useTranslation();
  1386. const [form, setForm] = React.useState({ name: "", description: "" });
  1387. const [error, setError] = React.useState<string>();
  1388. const [submitting, setSubmitting] = React.useState(false);
  1389. async function submit(event: React.FormEvent) {
  1390. event.preventDefault();
  1391. setError(undefined);
  1392. setSubmitting(true);
  1393. try {
  1394. await createKnowledgeBase({ name: form.name.trim(), description: form.description.trim() || null, metadata: {} });
  1395. toast.success(t("knowledge.knowledgeBaseCreated"));
  1396. onOpenChange(false);
  1397. setForm({ name: "", description: "" });
  1398. onCreated();
  1399. } finally {
  1400. setSubmitting(false);
  1401. }
  1402. }
  1403. return (
  1404. <Dialog open={open} onOpenChange={onOpenChange} title={t("knowledge.createKnowledgeBase")} className="max-w-2xl">
  1405. <form className="space-y-4" onSubmit={submit}>
  1406. {error ? <p className="rounded-md border border-red-500/25 bg-red-500/10 p-3 text-sm text-red-700 dark:text-red-200">{error}</p> : null}
  1407. <Field label={t("common.name")}><Input required value={form.name} onChange={(event) => setForm({ ...form, name: event.target.value })} /></Field>
  1408. <Field label={t("common.description")}><Textarea value={form.description} onChange={(event) => setForm({ ...form, description: event.target.value })} /></Field>
  1409. <div className="flex justify-end gap-2">
  1410. <Button type="button" variant="ghost" onClick={() => onOpenChange(false)}>{t("common.cancel")}</Button>
  1411. <Button disabled={submitting}>{submitting ? t("common.creating") : t("common.create")}</Button>
  1412. </div>
  1413. </form>
  1414. </Dialog>
  1415. );
  1416. }
  1417. function CreateKnowledgeDocumentDialog({
  1418. open,
  1419. onOpenChange,
  1420. knowledgeBaseId,
  1421. knowledgeBaseName,
  1422. onCreated,
  1423. }: {
  1424. open: boolean;
  1425. onOpenChange: (open: boolean) => void;
  1426. knowledgeBaseId?: string;
  1427. knowledgeBaseName?: string;
  1428. onCreated: (ingest: KnowledgeDocumentIngestResponse) => void;
  1429. }) {
  1430. const { t } = useTranslation();
  1431. const [form, setForm] = React.useState({
  1432. title: "",
  1433. sourceType: "text",
  1434. sourceUri: "",
  1435. contentText: "",
  1436. chunkSize: "800",
  1437. chunkOverlap: "120",
  1438. });
  1439. const [parsePreview, setParsePreview] = React.useState<KnowledgeDocumentParseResponse>();
  1440. const [error, setError] = React.useState<string>();
  1441. const [parsing, setParsing] = React.useState(false);
  1442. const [submitting, setSubmitting] = React.useState(false);
  1443. async function previewParse() {
  1444. setError(undefined);
  1445. setParsing(true);
  1446. try {
  1447. const parsed = await parseKnowledgeDocument({
  1448. sourceType: form.sourceType,
  1449. sourceUri: form.sourceUri.trim() || null,
  1450. contentText: form.contentText,
  1451. });
  1452. setParsePreview(parsed);
  1453. toast.success(t("knowledge.parsePreviewReady"));
  1454. } catch (err) {
  1455. setError(translateApiError(err));
  1456. } finally {
  1457. setParsing(false);
  1458. }
  1459. }
  1460. async function submit(event: React.FormEvent) {
  1461. event.preventDefault();
  1462. if (!knowledgeBaseId) return;
  1463. setError(undefined);
  1464. setSubmitting(true);
  1465. try {
  1466. const ingest = await createKnowledgeDocument({
  1467. knowledgeBaseId,
  1468. title: form.title.trim(),
  1469. sourceType: form.sourceType,
  1470. sourceUri: form.sourceUri.trim() || null,
  1471. contentText: form.contentText,
  1472. chunkSize: Number(form.chunkSize) || null,
  1473. chunkOverlap: Number(form.chunkOverlap) || null,
  1474. metadata: {},
  1475. });
  1476. toast.success(ingest.queued ? t("knowledge.documentQueued") : t("knowledge.documentIndexedWithChunks", { count: ingest.chunks.length }));
  1477. onOpenChange(false);
  1478. setForm({ title: "", sourceType: "text", sourceUri: "", contentText: "", chunkSize: "800", chunkOverlap: "120" });
  1479. setParsePreview(undefined);
  1480. onCreated(ingest);
  1481. } catch (err) {
  1482. setError(translateApiError(err));
  1483. } finally {
  1484. setSubmitting(false);
  1485. }
  1486. }
  1487. return (
  1488. <Dialog open={open} onOpenChange={onOpenChange} title={t("knowledge.addDocument")} className="max-w-4xl">
  1489. <form className="space-y-4" onSubmit={submit}>
  1490. {error ? <p className="rounded-md border border-red-500/25 bg-red-500/10 p-3 text-sm text-red-700 dark:text-red-200">{error}</p> : null}
  1491. <div className="rounded-md border border-primary/20 bg-primary/10 p-3">
  1492. <p className="text-sm font-medium">{t("knowledge.targetBase")}: {knowledgeBaseName ?? t("knowledge.noBaseSelected")}</p>
  1493. <p className="mt-1 text-xs leading-5 text-muted-foreground">
  1494. {t("knowledge.documentTargetHint")}
  1495. </p>
  1496. </div>
  1497. <div className="grid gap-3 lg:grid-cols-2">
  1498. <Field label={t("knowledge.addDocumentTitle")}><Input required value={form.title} onChange={(event) => setForm({ ...form, title: event.target.value })} /></Field>
  1499. <Field label={t("knowledge.sourceType")}>
  1500. <Select value={form.sourceType} onChange={(event) => setForm({ ...form, sourceType: event.target.value })} options={sourceTypeValues.filter((value) => value !== "all").map((value) => ({ value, label: t(`knowledge.sourceLabels.${value}`) }))} />
  1501. </Field>
  1502. </div>
  1503. <Field label={t("knowledge.sourceUri")}><Input value={form.sourceUri} onChange={(event) => setForm({ ...form, sourceUri: event.target.value })} placeholder="https://docs.example.com/page" /></Field>
  1504. <Field label={t("knowledge.content")}><Textarea required className="min-h-40" value={form.contentText} onChange={(event) => setForm({ ...form, contentText: event.target.value })} /></Field>
  1505. <div className="grid gap-3 lg:grid-cols-2">
  1506. <Field label={t("knowledge.chunkSize")}><Input inputMode="numeric" value={form.chunkSize} onChange={(event) => setForm({ ...form, chunkSize: event.target.value })} /></Field>
  1507. <Field label={t("knowledge.chunkOverlap")}><Input inputMode="numeric" value={form.chunkOverlap} onChange={(event) => setForm({ ...form, chunkOverlap: event.target.value })} /></Field>
  1508. </div>
  1509. {parsePreview ? (
  1510. <div className="rounded-md border border-border bg-muted/30 p-3">
  1511. <p className="text-sm font-medium">{t("knowledge.parsePreview")}</p>
  1512. <p className="mt-1 line-clamp-3 text-sm text-muted-foreground">{parsePreview.contentText}</p>
  1513. <MetadataSummary metadata={parsePreview.metadata} />
  1514. </div>
  1515. ) : null}
  1516. <div className="flex flex-wrap justify-end gap-2">
  1517. <Button type="button" variant="ghost" onClick={() => onOpenChange(false)}>{t("common.cancel")}</Button>
  1518. <Button type="button" variant="secondary" disabled={parsing || !form.contentText.trim()} onClick={() => void previewParse()}>
  1519. {parsing ? t("knowledge.parsing") : t("knowledge.previewParse")}
  1520. </Button>
  1521. <Button disabled={submitting || !knowledgeBaseId}>{submitting ? t("knowledge.indexing") : t("knowledge.indexDocument")}</Button>
  1522. </div>
  1523. </form>
  1524. </Dialog>
  1525. );
  1526. }
  1527. function Detail({ label, value }: { label: string; value: string }) {
  1528. return (
  1529. <div className="min-w-0">
  1530. <p className="text-xs text-muted-foreground">{label}</p>
  1531. <p className="mt-1 break-words font-mono text-xs text-foreground">{value}</p>
  1532. </div>
  1533. );
  1534. }
  1535. function Field({ label, children }: { label: string; children: React.ReactNode }) {
  1536. return <label className="block space-y-2 text-sm"><span className="text-muted-foreground">{label}</span>{children}</label>;
  1537. }
  1538. function formatKnowledgeJobType(value: string, t: ReturnType<typeof useTranslation>["t"]) {
  1539. const known: Record<string, string> = {
  1540. "Sitemap Sync": "sitemapSync",
  1541. "Re-index": "reindex",
  1542. "PDF Batch": "pdfBatch",
  1543. index: "index",
  1544. reindex: "reindex",
  1545. };
  1546. const key = known[value];
  1547. return key ? t(`knowledge.jobTypes.${key}`) : value;
  1548. }
  1549. function toKnowledgeJob(job: KnowledgeIndexJob): KnowledgeJob {
  1550. return {
  1551. id: job.jobId,
  1552. type: job.action,
  1553. target: job.documentTitle || job.documentId,
  1554. status: job.status,
  1555. progress: job.progress,
  1556. createdTime: job.queuedTime || job.startedTime || job.completedTime || new Date().toISOString(),
  1557. workerKey: job.workerKey,
  1558. errorMessage: job.errorMessage,
  1559. };
  1560. }
  1561. function formatKnowledgeJobTarget(value: string, t: ReturnType<typeof useTranslation>["t"]) {
  1562. const known: Record<string, string> = {
  1563. "Product Docs": "productDocs",
  1564. "Policy Pack": "policyPack",
  1565. };
  1566. const key = known[value];
  1567. return key ? t(`knowledge.jobTargets.${key}`) : value;
  1568. }
  1569. function formatEvalText(value: string, t: ReturnType<typeof useTranslation>["t"]) {
  1570. const known: Record<string, string> = {
  1571. "Where can customers download invoices?": "downloadInvoices",
  1572. "Billing and Invoice FAQ": "billingInvoiceFaq",
  1573. "How do refunds affect annual plans?": "annualPlanRefunds",
  1574. "Refund policy": "refundPolicy",
  1575. };
  1576. const key = known[value];
  1577. return key ? t(`knowledge.evalSamples.${key}`) : value;
  1578. }
  1579. function humanizeCode(value: string) {
  1580. return value
  1581. .replace(/_/g, " ")
  1582. .replace(/\b\w/g, (letter) => letter.toUpperCase());
  1583. }
  1584. function getReadableEntries(metadata?: JSONObject | null): Array<[string, unknown]> {
  1585. return Object.entries(metadata ?? {}).filter(([, value]) => value !== undefined && value !== null && value !== "");
  1586. }
  1587. function formatPropertyLabel(value: string, t?: ReturnType<typeof useTranslation>["t"]) {
  1588. const normalized = value.replace(/_json$/i, "");
  1589. const fallback = normalized
  1590. .replace(/_json$/i, "")
  1591. .replace(/_/g, " ")
  1592. .replace(/\b\w/g, (letter) => letter.toUpperCase());
  1593. return t ? t(`knowledge.propertyLabels.${normalized}`, fallback) : fallback;
  1594. }
  1595. function formatPropertyValue(value: unknown, t?: ReturnType<typeof useTranslation>["t"]): string {
  1596. if (Array.isArray(value)) {
  1597. return value.map((item) => formatPropertyValue(item, t)).join(", ");
  1598. }
  1599. if (typeof value === "object" && value !== null) {
  1600. const summary = Object.entries(value)
  1601. .slice(0, 3)
  1602. .map(([key, item]) => `${formatPropertyLabel(key, t)}: ${formatPropertyValue(item, t)}`)
  1603. .join("; ");
  1604. return summary || (t ? t("common.configured") : "Configured");
  1605. }
  1606. if (typeof value === "boolean") {
  1607. return value ? (t ? t("common.yes") : "Yes") : (t ? t("common.no") : "No");
  1608. }
  1609. return String(value);
  1610. }