| 1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702 |
- import * as React from "react";
- import { useTranslation } from "react-i18next";
- import { NavLink, useNavigate, useParams } from "react-router-dom";
- import {
- Archive,
- BarChart3,
- BookOpen,
- Bot,
- ClipboardCheck,
- Database,
- FilePlus,
- FileSearch,
- FileText,
- Filter,
- Gauge,
- Layers3,
- ListChecks,
- RefreshCw,
- RotateCcw,
- Search,
- Settings2,
- ShieldCheck,
- SlidersHorizontal,
- Sparkles,
- UploadCloud,
- type LucideIcon,
- } from "lucide-react";
- import {
- createKnowledgeBase,
- createKnowledgeDocument,
- getKnowledgeSettings,
- listKnowledgeIndexJobs,
- listKnowledgeBases,
- listKnowledgeChunks,
- listKnowledgeDocuments,
- listModels,
- parseKnowledgeDocument,
- reindexKnowledgeBase,
- searchKnowledge,
- updateKnowledgeSettings,
- updateKnowledgeBaseStatus,
- } from "@/api";
- import { translateApiError } from "@/api/errors";
- import { ApiErrorState } from "@/components/shared/ApiErrorState";
- import { EmptyState } from "@/components/shared/EmptyState";
- import { LoadingSpinner } from "@/components/shared/LoadingSpinner";
- import { MetricCard } from "@/components/shared/MetricCard";
- import { PageHeader } from "@/components/shared/PageHeader";
- import { SearchInput } from "@/components/shared/SearchInput";
- import { StatusBadge } from "@/components/shared/StatusBadge";
- import { Badge } from "@/components/ui/badge";
- import { Button } from "@/components/ui/button";
- import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
- import { Dialog } from "@/components/ui/dialog";
- import { Input, Textarea } from "@/components/ui/input";
- import { Select } from "@/components/ui/select";
- import { Tabs } from "@/components/ui/tabs";
- import { toast } from "@/components/ui/toaster";
- import { demoText } from "@/lib/demo-text";
- import { formatDateTime } from "@/lib/utils";
- import type { JSONObject, KnowledgeBase, KnowledgeChunk, KnowledgeDocument, KnowledgeDocumentIngestResponse, KnowledgeDocumentParseResponse, KnowledgeIndexJob, ModelDefinition, SearchResult } from "@/types";
- import type { KnowledgeSettingsPayload } from "@/api/knowledge";
- const documentStatusValues = ["all", "queued", "indexing", "indexed", "draft", "failed", "archived"] as const;
- const sourceTypeValues = ["all", "text", "markdown", "json", "html", "pdf"] as const;
- type KnowledgeJob = {
- id: string;
- type: string;
- target: string;
- status: KnowledgeIndexJob["status"];
- progress: number;
- createdTime: string;
- workerKey?: string | null;
- errorMessage?: string | null;
- };
- type EvalCase = {
- id: string;
- query: string;
- expected: string;
- status: "draft" | "passed" | "failed";
- recall: number;
- precision: number;
- };
- type RetrievalConfig = {
- retrievalMode: string;
- embeddingModelId: string;
- rerankModelId: string;
- chunkSize: string;
- chunkOverlap: string;
- topK: string;
- minScore: string;
- maxCandidates: string;
- keywordWeight: string;
- vectorWeight: string;
- rerankWeight: string;
- queryRewrite: boolean;
- requireCitations: boolean;
- };
- const defaultRetrievalConfig: RetrievalConfig = {
- retrievalMode: "hybrid",
- embeddingModelId: "auto",
- rerankModelId: "auto",
- chunkSize: "800",
- chunkOverlap: "120",
- topK: "5",
- minScore: "0.20",
- maxCandidates: "50",
- keywordWeight: "0.30",
- vectorWeight: "0.55",
- rerankWeight: "0.15",
- queryRewrite: true,
- requireCitations: true,
- };
- const knowledgeSections = [
- { value: "overview", label: "Knowledge Bases", path: "/knowledge", icon: Database },
- { value: "documents", label: "Documents", path: "/knowledge/documents", icon: FileText },
- { value: "playground", label: "Test Search", path: "/knowledge/playground", icon: Search },
- { value: "evaluation", label: "Quality", path: "/knowledge/evaluation", icon: ClipboardCheck },
- { value: "jobs", label: "Index Jobs", path: "/knowledge/jobs", icon: ListChecks },
- { value: "analytics", label: "Analytics", path: "/knowledge/analytics", icon: BarChart3 },
- { value: "settings", label: "Settings", path: "/knowledge/settings", icon: Settings2 },
- ];
- const capabilityGroups = [
- {
- titleKey: "knowledge.ingestion",
- icon: UploadCloud,
- items: [
- { labelKey: "knowledge.textMarkdownIngest", state: "live" },
- { labelKey: "knowledge.parsePreviewIng", state: "live" },
- { labelKey: "knowledge.pdfDocxHtmlParser", state: "prototype" },
- { labelKey: "knowledge.urlSitemapGithub", state: "prototype" },
- ],
- },
- {
- titleKey: "knowledge.indexingTab",
- icon: Layers3,
- items: [
- { labelKey: "knowledge.chunkSizeOverlap", state: "live" },
- { labelKey: "knowledge.embeddingModelTracking", state: "mock" },
- { labelKey: "knowledge.reindexDocumentBase", state: "prototype" },
- { labelKey: "knowledge.indexJobQueue", state: "prototype" },
- ],
- },
- {
- titleKey: "knowledge.retrievalTab",
- icon: Search,
- items: [
- { labelKey: "knowledge.hybridSearchPlayground", state: "live" },
- { labelKey: "knowledge.topKMetadataFilters", state: "live" },
- { labelKey: "knowledge.scoreBreakdownCitations", state: "live" },
- { labelKey: "knowledge.rerankQueryRewrite", state: "mock" },
- ],
- },
- {
- titleKey: "knowledge.evaluation",
- icon: ClipboardCheck,
- items: [
- { labelKey: "knowledge.goldenQueries", state: "prototype" },
- { labelKey: "knowledge.recallPrecisionMetrics", state: "prototype" },
- { labelKey: "knowledge.humanRelevanceFeedback", state: "prototype" },
- { labelKey: "knowledge.regressionComparison", state: "prototype" },
- ],
- },
- {
- titleKey: "knowledge.governance",
- icon: ShieldCheck,
- items: [
- { labelKey: "knowledge.archiveRestoreBase", state: "live" },
- { labelKey: "knowledge.documentAclPii", state: "prototype" },
- { labelKey: "knowledge.auditLog", state: "prototype" },
- ],
- },
- {
- titleKey: "knowledge.integration",
- icon: Bot,
- items: [
- { labelKey: "knowledge.agentBinding", state: "prototype" },
- { labelKey: "knowledge.workflowRetrievalNode", state: "prototype" },
- { labelKey: "knowledge.knowledgeSearchTool", state: "prototype" },
- { labelKey: "knowledge.runTraceCitations", state: "prototype" },
- ],
- },
- ];
- export function KnowledgePage() {
- const { t } = useTranslation();
- const navigate = useNavigate();
- const documentStatusOptions = documentStatusValues.map((value) => ({
- value,
- label: t(`knowledge.statusLabels.${value}`),
- }));
- const sourceTypeOptions = sourceTypeValues.map((value) => ({
- value,
- label: t(`knowledge.sourceLabels.${value}`),
- }));
- const { section: sectionParam } = useParams();
- const section = knowledgeSections.some((item) => item.value === sectionParam) ? sectionParam ?? "overview" : "overview";
- const [bases, setBases] = React.useState<KnowledgeBase[]>([]);
- const [documents, setDocuments] = React.useState<KnowledgeDocument[]>([]);
- const [chunksByDocument, setChunksByDocument] = React.useState<Record<string, KnowledgeChunk[]>>({});
- const [models, setModels] = React.useState<ModelDefinition[]>([]);
- const [results, setResults] = React.useState<SearchResult[]>([]);
- const [selectedBaseId, setSelectedBaseId] = React.useState<string>();
- const [scopeMode] = React.useState<"current" | "all">("current");
- const [selectedDocumentId, setSelectedDocumentId] = React.useState<string>();
- const [baseSearch, setBaseSearch] = React.useState("");
- const [documentSearch, setDocumentSearch] = React.useState("");
- const [documentStatus, setDocumentStatus] = React.useState("all");
- const [sourceType, setSourceType] = React.useState("all");
- const [query, setQuery] = React.useState("");
- const [topK, setTopK] = React.useState("5");
- const [activeTab, setActiveTab] = React.useState("overview");
- const [loading, setLoading] = React.useState(true);
- const [documentsLoading, setDocumentsLoading] = React.useState(false);
- const [searching, setSearching] = React.useState(false);
- const [statusBusy, setStatusBusy] = React.useState(false);
- const [error, setError] = React.useState<string>();
- const [createOpen, setCreateOpen] = React.useState(false);
- const [documentOpen, setDocumentOpen] = React.useState(false);
- const [lastIngest, setLastIngest] = React.useState<KnowledgeDocumentIngestResponse>();
- const [jobs, setJobs] = React.useState<KnowledgeJob[]>([]);
- const [evalCases, setEvalCases] = React.useState<EvalCase[]>([]);
- const [retrievalConfig, setRetrievalConfig] = React.useState<RetrievalConfig>(defaultRetrievalConfig);
- const selectedBase = bases.find((base) => base.id === selectedBaseId);
- const selectedBases = scopeMode === "all" ? bases : selectedBase ? [selectedBase] : [];
- const activeBaseIds = scopeMode === "all" ? bases.map((base) => base.id) : selectedBaseId ? [selectedBaseId] : [];
- const activeBaseKey = activeBaseIds.join("|");
- const baseNameById = React.useMemo(() => new Map(bases.map((base) => [base.id, demoText(base.name, t)])), [bases, t]);
- const selectedDocument = documents.find((document) => document.id === selectedDocumentId);
- const sourceTypes = React.useMemo(() => Array.from(new Set(documents.map((document) => document.sourceType))).sort(), [documents]);
- const indexedCount = documents.filter((document) => document.status === "indexed").length;
- const filteredBases = bases.filter((base) => `${base.name} ${base.description ?? ""}`.toLowerCase().includes(baseSearch.toLowerCase()));
- const filteredDocuments = documents.filter((document) => {
- const matchesText = `${document.title} ${demoText(document.title, t)} ${document.sourceUri ?? ""} ${document.sourceType}`.toLowerCase().includes(documentSearch.toLowerCase());
- const matchesStatus = documentStatus === "all" || document.status === documentStatus;
- const matchesSource = sourceType === "all" || document.sourceType === sourceType;
- return matchesText && matchesStatus && matchesSource;
- });
- const selectedDocumentResults = selectedDocument
- ? results.filter((result) => result.document.id === selectedDocument.id || result.chunk.documentId === selectedDocument.id)
- : [];
- const selectedDocumentChunks = React.useMemo(() => {
- const chunks = [
- ...(selectedDocumentId ? chunksByDocument[selectedDocumentId] ?? [] : []),
- ...(lastIngest && lastIngest.document.id === selectedDocumentId ? lastIngest.chunks : []),
- ...results.filter((result) => result.chunk.documentId === selectedDocumentId).map((result) => result.chunk),
- ];
- return Array.from(new Map(chunks.map((chunk) => [chunk.id, chunk])).values());
- }, [chunksByDocument, lastIngest, results, selectedDocumentId]);
- const loadBases = React.useCallback(async () => {
- setLoading(true);
- setError(undefined);
- try {
- const data = await listKnowledgeBases();
- setBases(data);
- setSelectedBaseId((current) => current ?? data[0]?.id);
- } catch (err) {
- setError(translateApiError(err));
- } finally {
- setLoading(false);
- }
- }, [t]);
- const loadDocuments = React.useCallback(async (knowledgeBaseIds?: string[]) => {
- if (!knowledgeBaseIds?.length) {
- setDocuments([]);
- setChunksByDocument({});
- setResults([]);
- setSelectedDocumentId(undefined);
- return;
- }
- setDocumentsLoading(true);
- try {
- const [documentGroups, chunkGroups] = await Promise.all([
- Promise.all(knowledgeBaseIds.map((knowledgeBaseId) => listKnowledgeDocuments(knowledgeBaseId))),
- Promise.all(knowledgeBaseIds.map((knowledgeBaseId) => listKnowledgeChunks(knowledgeBaseId).catch(() => [] as KnowledgeChunk[]))),
- ]);
- const data = documentGroups.flat();
- setDocuments(data);
- setChunksByDocument(groupChunksByDocument(chunkGroups.flat()));
- setSelectedDocumentId((current) => (current && data.some((document) => document.id === current) ? current : data[0]?.id));
- setResults([]);
- } catch {
- setDocuments([]);
- setChunksByDocument({});
- setSelectedDocumentId(undefined);
- toast.error(t("knowledge.failedToLoadDocuments"));
- } finally {
- setDocumentsLoading(false);
- }
- }, [t]);
- const loadJobs = React.useCallback(async (knowledgeBaseIds?: string[]) => {
- if (!knowledgeBaseIds?.length) {
- setJobs([]);
- return;
- }
- try {
- const jobGroups = await Promise.all(knowledgeBaseIds.map((knowledgeBaseId) => listKnowledgeIndexJobs(knowledgeBaseId)));
- setJobs(jobGroups.flat().map(toKnowledgeJob));
- } catch {
- setJobs([]);
- }
- }, []);
- React.useEffect(() => {
- void loadBases();
- }, [loadBases]);
- React.useEffect(() => {
- let mounted = true;
- async function loadConfiguredModels() {
- try {
- const data = await listModels();
- if (mounted) setModels(data);
- } catch {
- if (mounted) setModels([]);
- }
- }
- void loadConfiguredModels();
- return () => {
- mounted = false;
- };
- }, []);
- React.useEffect(() => {
- void loadDocuments(activeBaseIds);
- }, [loadDocuments, activeBaseKey]);
- React.useEffect(() => {
- void loadJobs(activeBaseIds);
- }, [loadJobs, activeBaseKey]);
- React.useEffect(() => {
- let mounted = true;
- async function loadRetrievalSettings() {
- if (!selectedBaseId) {
- if (mounted) setRetrievalConfig(defaultRetrievalConfig);
- return;
- }
- try {
- const data = await getKnowledgeSettings(selectedBaseId);
- if (mounted) setRetrievalConfig(settingsPayloadToForm(data));
- } catch {
- if (mounted) setRetrievalConfig(defaultRetrievalConfig);
- }
- }
- void loadRetrievalSettings();
- return () => {
- mounted = false;
- };
- }, [selectedBaseId]);
- async function runSearch(event?: React.FormEvent) {
- event?.preventDefault();
- if (!activeBaseIds.length || !query.trim()) return;
- setSearching(true);
- try {
- const filters: JSONObject = {};
- if (documentStatus !== "all") filters.status = documentStatus;
- if (sourceType !== "all") filters.sourceType = sourceType;
- const data = (await Promise.all(activeBaseIds.map((baseId) => searchKnowledge(baseId, query.trim(), Number(topK) || 5, filters)))).flat();
- data.sort((a, b) => b.score - a.score);
- setResults(data);
- setActiveTab("search");
- if (section !== "playground") navigate("/knowledge/playground");
- toast.info(data.length ? t("knowledge.searchResults", { count: data.length }) : t("knowledge.noMatchingChunks"));
- } catch (err) {
- toast.error(translateApiError(err));
- } finally {
- setSearching(false);
- }
- }
- async function reloadSelectedBase() {
- await Promise.all([loadBases(), loadDocuments(activeBaseIds), loadJobs(activeBaseIds)]);
- }
- function selectBase(baseId: string) {
- setSelectedBaseId(baseId);
- setSelectedDocumentId(undefined);
- }
- async function toggleBaseStatus() {
- if (!selectedBase) return;
- setStatusBusy(true);
- try {
- const nextStatus = selectedBase.status === "active" ? "archived" : "active";
- const updated = await updateKnowledgeBaseStatus({ knowledgeBaseId: selectedBase.id, status: nextStatus });
- setBases((items) => items.map((base) => (base.id === updated.id ? updated : base)));
- toast.success(nextStatus === "active" ? t("knowledge.knowledgeBaseRestored") : t("knowledge.knowledgeBaseArchived"));
- } finally {
- setStatusBusy(false);
- }
- }
- async function createJobsForActiveBases(type: string) {
- const targets = selectedBases.length ? selectedBases : selectedBase ? [selectedBase] : [];
- for (const base of targets) {
- try {
- const result = await reindexKnowledgeBase({
- knowledgeBaseId: base.id,
- chunkSize: Number(retrievalConfig.chunkSize) || undefined,
- chunkOverlap: Number(retrievalConfig.chunkOverlap) || undefined,
- });
- setJobs((items) => [...result.jobs.map(toKnowledgeJob), ...items.filter((item) => !result.jobs.some((job) => job.jobId === item.id))]);
- toast.success(t("knowledge.createdJob", { type: formatKnowledgeJobType(type, t) }));
- await Promise.all([loadDocuments(activeBaseIds), loadJobs(activeBaseIds)]);
- } catch {
- toast.error(t("knowledge.documentIngestFailed"));
- }
- }
- }
- function addEvalCase(queryText: string, expected: string) {
- setEvalCases((items) => [
- { id: `eval_${Date.now().toString(36)}`, query: queryText, expected, status: "draft", recall: 0, precision: 0 },
- ...items,
- ]);
- }
- async function runEvalCase(evalId: string) {
- const item = evalCases.find((evalCase) => evalCase.id === evalId);
- if (!item || !activeBaseIds.length) return;
- const data = (await Promise.all(activeBaseIds.map((baseId) => searchKnowledge(baseId, item.query, Number(topK) || 5)))).flat();
- const expected = item.expected.toLowerCase();
- const matched = data.some((result) =>
- `${result.document.title} ${result.chunk.contentText}`.toLowerCase().includes(expected));
- const scoreAverage = average(data.map((result) => result.score));
- setEvalCases((items) =>
- items.map((evalCase) =>
- evalCase.id === evalId
- ? {
- ...evalCase,
- status: matched ? "passed" : "failed",
- recall: matched ? 1 : 0,
- precision: Number(Math.min(scoreAverage || 0, 1).toFixed(2)),
- }
- : evalCase));
- toast.success(t("knowledge.evaluationCaseFinished"));
- }
- function updateRetrievalConfig(nextConfig: RetrievalConfig) {
- setRetrievalConfig(nextConfig);
- if (!selectedBaseId) return;
- void updateKnowledgeSettings(formToSettingsPayload(nextConfig, selectedBaseId)).catch(() => {
- toast.error(t("common.failedToSave"));
- });
- }
- const isOverview = section === "overview";
- const pageTitle = isOverview ? t("knowledge.knowledgeBases") : demoText(selectedBase?.name, t) || t("knowledge.title");
- const pageDescription = isOverview
- ? t("knowledge.description")
- : t("knowledge.manageInsideBase");
- if (loading) return <LoadingSpinner label={t("knowledge.loading")} />;
- if (error) return <ApiErrorState message={error} onRetry={() => void loadBases()} />;
- return (
- <div className="space-y-6">
- <PageHeader
- title={pageTitle}
- description={pageDescription}
- actions={(
- <div className="flex flex-wrap gap-2">
- {!isOverview ? (
- <Button variant="secondary" onClick={() => navigate("/knowledge")}>
- <Database className="h-4 w-4" /> {t("knowledge.manageBases")}
- </Button>
- ) : null}
- {isOverview ? <Button onClick={() => setCreateOpen(true)}><BookOpen className="h-4 w-4" /> {t("knowledge.newBase")}</Button> : null}
- <Button size="icon" variant="ghost" aria-label={t("knowledge.refresh")} onClick={() => void reloadSelectedBase()}>
- <RefreshCw className="h-4 w-4" />
- </Button>
- </div>
- )}
- />
- {!isOverview ? (
- <>
- <KnowledgeScopeBar
- bases={bases}
- selectedBaseId={selectedBaseId}
- documentCount={documents.length}
- indexedCount={indexedCount}
- jobCount={jobs.length}
- onSelectBase={selectBase}
- onAddDocument={() => setDocumentOpen(true)}
- onTestSearch={() => navigate("/knowledge/playground")}
- onReindex={() => createJobsForActiveBases("Re-index")}
- onSettings={() => navigate("/knowledge/settings")}
- />
- <KnowledgeSectionNav />
- </>
- ) : null}
- {section === "overview" ? (
- <div className="space-y-6">
- <div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
- <MetricCard label={t("knowledge.knowledgeBases")} value={bases.length} icon={Database} />
- <MetricCard label={t("knowledge.documents")} value={documents.length} icon={FileText} />
- <MetricCard label={t("knowledge.indexed")} value={indexedCount} icon={Sparkles} />
- <MetricCard label={t("knowledge.sources")} value={sourceTypes.length} icon={Filter} />
- </div>
- <div className="grid gap-6 2xl:grid-cols-[minmax(280px,360px)_minmax(0,1fr)]">
- <KnowledgeBaseList
- bases={filteredBases}
- baseSearch={baseSearch}
- onSearch={setBaseSearch}
- selectedBaseId={selectedBaseId}
- onCreate={() => setCreateOpen(true)}
- onSelect={selectBase}
- />
- <div className="space-y-6">
- <BaseSummaryCard selectedBase={selectedBase} selectedBases={selectedBases} documents={documents} indexedCount={indexedCount} statusBusy={statusBusy} onToggleStatus={() => void toggleBaseStatus()} onOpenBase={() => navigate("/knowledge/documents")} />
- <KnowledgeCapabilityBoard compact />
- </div>
- </div>
- </div>
- ) : null}
- {section === "documents" ? (
- <div className="grid gap-6 2xl:grid-cols-[minmax(0,1fr)_420px]">
- <DocumentsPanel
- documentsLoading={documentsLoading}
- documents={documents}
- filteredDocuments={filteredDocuments}
- documentSearch={documentSearch}
- documentStatus={documentStatus}
- selectedDocumentId={selectedDocumentId}
- baseNameById={baseNameById}
- canAdd={Boolean(activeBaseIds.length)}
- onSearch={setDocumentSearch}
- onStatus={setDocumentStatus}
- onAdd={() => setDocumentOpen(true)}
- onSelect={(documentId) => {
- setSelectedDocumentId(documentId);
- setActiveTab("overview");
- }}
- />
- <DocumentInspector
- activeTab={activeTab}
- onTab={setActiveTab}
- selectedBase={selectedBase}
- selectedDocument={selectedDocument}
- selectedDocumentResults={selectedDocumentResults}
- selectedDocumentChunks={selectedDocumentChunks}
- lastIngest={lastIngest}
- />
- </div>
- ) : null}
- {section === "playground" ? (
- <div className="grid gap-6 2xl:grid-cols-[minmax(0,1fr)_420px]">
- <Card>
- <CardHeader>
- <CardTitle>{t("knowledge.retrievalPlayground")}</CardTitle>
- <p className="text-sm text-muted-foreground">
- {t("knowledge.askAgainstBase", { name: selectedBase?.name ?? t("knowledge.currentBase") })}
- </p>
- </CardHeader>
- <CardContent className="space-y-4">
- <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)}>
- <Input aria-label={t("knowledge.query")} value={query} onChange={(event) => setQuery(event.target.value)} placeholder={t("knowledge.askRetrievalQuestion")} />
- <Select aria-label={t("knowledge.searchSourceFilter")} value={sourceType} onChange={(event) => setSourceType(event.target.value)} options={sourceTypeOptions} />
- <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}` }))} />
- <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>
- </form>
- {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")} />}
- </CardContent>
- </Card>
- <RetrievalSettingsPanel config={retrievalConfig} models={models} onChange={updateRetrievalConfig} />
- </div>
- ) : null}
- {section === "evaluation" ? <EvaluationPage evalCases={evalCases} onAdd={addEvalCase} onRun={runEvalCase} /> : null}
- {section === "jobs" ? <JobsPage jobs={jobs} onCreateReindex={() => createJobsForActiveBases("Re-index")} /> : null}
- {section === "analytics" ? <AnalyticsPage documents={documents} results={results} jobs={jobs} evalCases={evalCases} /> : null}
- {section === "settings" ? (
- <div>
- <RetrievalSettingsPanel config={retrievalConfig} models={models} onChange={updateRetrievalConfig} />
- </div>
- ) : null}
- <CreateKnowledgeBaseDialog open={createOpen} onOpenChange={setCreateOpen} onCreated={() => void loadBases()} />
- <CreateKnowledgeDocumentDialog
- open={documentOpen}
- onOpenChange={setDocumentOpen}
- knowledgeBaseId={selectedBaseId ?? activeBaseIds[0]}
- knowledgeBaseName={baseNameById.get(selectedBaseId ?? activeBaseIds[0] ?? "")}
- onCreated={(ingest) => {
- setLastIngest(ingest);
- setChunksByDocument((current) => ({ ...current, [ingest.document.id]: ingest.chunks }));
- if (ingest.job) {
- setJobs((items) => [toKnowledgeJob(ingest.job!), ...items.filter((item) => item.id !== ingest.job?.jobId)]);
- }
- setSelectedDocumentId(ingest.document.id);
- setActiveTab("overview");
- void Promise.all([loadDocuments(activeBaseIds), loadJobs(activeBaseIds)]);
- }}
- />
- </div>
- );
- }
- function KnowledgeSectionNav() {
- const { t } = useTranslation();
- const workspaceSections = knowledgeSections.filter((item) => item.value !== "overview");
- return (
- <div className="overflow-x-auto rounded-md border border-border bg-surface-elevated p-1">
- <nav className="flex min-w-max gap-1">
- {workspaceSections.map((item) => (
- <NavLink
- key={item.value}
- to={item.path}
- className={({ isActive }) => [
- "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",
- isActive ? "bg-primary/15 text-primary" : "",
- ].join(" ")}
- >
- <item.icon className="h-4 w-4" />
- {t(`knowledge.sections.${item.value}`)}
- </NavLink>
- ))}
- </nav>
- </div>
- );
- }
- function KnowledgeScopeBar({
- bases,
- selectedBaseId,
- documentCount,
- indexedCount,
- jobCount,
- onSelectBase,
- onAddDocument,
- onTestSearch,
- onReindex,
- onSettings,
- }: {
- bases: KnowledgeBase[];
- selectedBaseId?: string;
- documentCount: number;
- indexedCount: number;
- jobCount: number;
- onSelectBase: (baseId: string) => void;
- onAddDocument: () => void;
- onTestSearch: () => void;
- onReindex: () => void;
- onSettings: () => void;
- }) {
- const { t } = useTranslation();
- const selectedBase = bases.find((base) => base.id === selectedBaseId);
- const hasBase = Boolean(selectedBase);
- return (
- <Card>
- <CardContent className="grid gap-4 p-4 2xl:grid-cols-[minmax(260px,360px)_minmax(0,1fr)_auto] 2xl:items-center">
- <div className="min-w-0">
- <p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">{t("knowledge.currentBase")}</p>
- <Select
- className="mt-2"
- value={selectedBaseId ?? ""}
- onChange={(event) => onSelectBase(event.target.value)}
- options={bases.map((base) => ({ value: base.id, label: demoText(base.name, t) }))}
- />
- </div>
- <div className="grid min-w-0 gap-2 sm:grid-cols-3">
- <ContextStat label={t("knowledge.documents")} value={documentCount} />
- <ContextStat label={t("knowledge.indexed")} value={indexedCount} />
- <ContextStat label={t("knowledge.jobs")} value={jobCount} />
- </div>
- <div className="flex min-w-0 flex-wrap gap-2 2xl:justify-end">
- <Button className="w-full sm:w-auto" size="sm" disabled={!hasBase} onClick={onAddDocument}>
- <FilePlus className="h-4 w-4" /> {t("knowledge.addDocument")}
- </Button>
- <Button className="w-full sm:w-auto" size="sm" variant="secondary" disabled={!hasBase} onClick={onTestSearch}>
- <Search className="h-4 w-4" /> {t("knowledge.testSearch")}
- </Button>
- <Button className="w-full sm:w-auto" size="sm" variant="secondary" disabled={!hasBase} onClick={onReindex}>
- <Layers3 className="h-4 w-4" /> {t("knowledge.reindex")}
- </Button>
- <Button className="w-11" size="icon" variant="ghost" disabled={!hasBase} aria-label={t("knowledge.openSettings")} onClick={onSettings}>
- <Settings2 className="h-4 w-4" />
- </Button>
- </div>
- </CardContent>
- </Card>
- );
- }
- function ContextStat({ label, value }: { label: string; value: number }) {
- return (
- <div className="min-w-0 rounded-md border border-border bg-muted/30 px-3 py-2">
- <p className="truncate text-xs text-muted-foreground">{label}</p>
- <p className="mt-1 font-mono text-sm font-semibold">{value}</p>
- </div>
- );
- }
- function KnowledgeBaseList({
- bases,
- baseSearch,
- selectedBaseId,
- onSearch,
- onSelect,
- onCreate,
- }: {
- bases: KnowledgeBase[];
- baseSearch: string;
- selectedBaseId?: string;
- onSearch: (value: string) => void;
- onSelect: (baseId: string) => void;
- onCreate: () => void;
- }) {
- const { t } = useTranslation();
- return (
- <Card>
- <CardHeader>
- <CardTitle>{t("knowledge.bases")}</CardTitle>
- </CardHeader>
- <CardContent className="space-y-4">
- <SearchInput value={baseSearch} onChange={onSearch} placeholder={t("knowledge.searchBases")} />
- {bases.length ? (
- <div className="space-y-2">
- {bases.map((base) => (
- <button
- key={base.id}
- type="button"
- onClick={() => onSelect(base.id)}
- className={[
- "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",
- base.id === selectedBaseId ? "border-primary/40 bg-primary/10" : "",
- ].join(" ")}
- >
- <span className="min-w-0">
- <span className="block truncate text-sm font-medium">{demoText(base.name, t)}</span>
- {base.description ? <span className="mt-1 block truncate font-mono text-xs text-muted-foreground">{demoText(base.description, t)}</span> : null}
- </span>
- <span className="shrink-0"><StatusBadge status={base.status} /></span>
- </button>
- ))}
- </div>
- ) : (
- <EmptyState icon={BookOpen} title={t("knowledge.noBasesFound")} description={t("knowledge.createOrSearch")} actionLabel={t("knowledge.newBase")} onAction={onCreate} />
- )}
- </CardContent>
- </Card>
- );
- }
- function BaseSummaryCard({
- selectedBase,
- selectedBases,
- documents,
- indexedCount,
- statusBusy,
- onToggleStatus,
- onOpenBase,
- }: {
- selectedBase?: KnowledgeBase;
- selectedBases: KnowledgeBase[];
- documents: KnowledgeDocument[];
- indexedCount: number;
- statusBusy: boolean;
- onToggleStatus: () => void;
- onOpenBase: () => void;
- }) {
- const { t } = useTranslation();
- const title = selectedBases.length > 1
- ? t("knowledge.knowledgeBases_plural", { count: selectedBases.length })
- : demoText(selectedBase?.name, t) || t("knowledge.selectKnowledgeBase");
- return (
- <Card>
- <CardHeader>
- <div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
- <div className="min-w-0">
- <CardTitle>{title}</CardTitle>
- <p className="mt-1 break-words text-sm text-muted-foreground">
- {selectedBases.length > 1 ? selectedBases.map((base) => demoText(base.name, t)).join(", ") : demoText(selectedBase?.description, t) || t("knowledge.chooseBaseManage")}
- </p>
- </div>
- <div className="flex flex-wrap gap-2 lg:justify-end">
- <Button className="w-full sm:w-auto" disabled={!selectedBase} onClick={onOpenBase}>
- <FileText className="h-4 w-4" /> {t("knowledge.openBase")}
- </Button>
- {selectedBase ? (
- <Button className="w-full sm:w-auto" variant="outline" disabled={statusBusy} onClick={onToggleStatus}>
- <Archive className="h-4 w-4" /> {selectedBase.status === "active" ? t("knowledge.archive") : t("knowledge.restoreCurrent")}
- </Button>
- ) : null}
- </div>
- </div>
- </CardHeader>
- <CardContent className="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
- <Detail label={t("knowledge.selected")} value={String(selectedBases.length || (selectedBase ? 1 : 0))} />
- <Detail label={t("common.status")} value={selectedBase ? t(`status.${selectedBase.status}`) : t("knowledge.notSelected")} />
- <Detail label={t("knowledge.documents")} value={String(documents.length)} />
- <Detail label={t("knowledge.indexed")} value={String(indexedCount)} />
- </CardContent>
- </Card>
- );
- }
- function DocumentsPanel({
- documentsLoading,
- documents,
- filteredDocuments,
- documentSearch,
- documentStatus,
- selectedDocumentId,
- baseNameById,
- canAdd,
- onSearch,
- onStatus,
- onAdd,
- onSelect,
- }: {
- documentsLoading: boolean;
- documents: KnowledgeDocument[];
- filteredDocuments: KnowledgeDocument[];
- documentSearch: string;
- documentStatus: string;
- selectedDocumentId?: string;
- baseNameById: Map<string, string>;
- canAdd: boolean;
- onSearch: (value: string) => void;
- onStatus: (value: string) => void;
- onAdd: () => void;
- onSelect: (documentId: string) => void;
- }) {
- const { t } = useTranslation();
- return (
- <Card>
- <CardHeader>
- <div className="flex flex-col gap-4 xl:flex-row xl:items-start xl:justify-between">
- <div className="min-w-0">
- <CardTitle>{t("knowledge.documents")}</CardTitle>
- <p className="mt-1 text-sm text-muted-foreground">{t("knowledge.documentsHint")}</p>
- </div>
- <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]">
- <SearchInput className="w-full sm:w-full" value={documentSearch} onChange={onSearch} placeholder={t("knowledge.searchDocuments")} />
- <Select aria-label={t("common.status")} value={documentStatus} onChange={(event) => onStatus(event.target.value)} options={documentStatusValues.map((value) => ({ value, label: t(`knowledge.statusLabels.${value}`) }))} />
- <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>
- </div>
- </div>
- </CardHeader>
- <CardContent>
- {documentsLoading ? (
- <LoadingSpinner label={t("knowledge.loadingDocuments")} />
- ) : filteredDocuments.length ? (
- <div className="grid gap-3 xl:grid-cols-2">
- {filteredDocuments.map((document) => (
- <button
- key={document.id}
- type="button"
- onClick={() => onSelect(document.id)}
- className={[
- "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",
- document.id === selectedDocumentId ? "border-primary/50 bg-primary/10" : "border-border bg-muted/30",
- ].join(" ")}
- >
- <div className="flex items-start justify-between gap-3">
- <div className="min-w-0">
- <p className="truncate text-sm font-semibold">{demoText(document.title, t)}</p>
- <p className="mt-1 truncate text-xs text-muted-foreground">
- {baseNameById.get(document.knowledgeBaseId) ?? t("knowledge.title")} / {document.sourceUri || t(`knowledge.sourceLabels.${document.sourceType}`, humanizeCode(document.sourceType))}
- </p>
- </div>
- <span className="shrink-0"><StatusBadge status={document.status} /></span>
- </div>
- <div className="mt-4 grid gap-3 text-xs text-muted-foreground sm:grid-cols-2">
- <Detail label={t("knowledge.source")} value={t(`knowledge.sourceLabels.${document.sourceType}`, humanizeCode(document.sourceType))} />
- <Detail label={t("knowledge.indexed")} value={document.indexedTime ? formatDateTime(document.indexedTime) : t("knowledge.pending")} />
- </div>
- <p className="mt-4 text-xs font-medium text-primary">{t("knowledge.openDetails")}</p>
- </button>
- ))}
- </div>
- ) : (
- <EmptyState
- icon={FileText}
- title={documents.length ? t("knowledge.noMatchingDocuments") : t("knowledge.noDocuments")}
- description={documents.length ? t("knowledge.adjustSearchOrStatus") : canAdd ? t("knowledge.addTextMarkdownJson") : t("knowledge.selectKnowledgeBaseFirst")}
- actionLabel={canAdd ? t("knowledge.addDocument") : undefined}
- onAction={canAdd ? onAdd : undefined}
- />
- )}
- </CardContent>
- </Card>
- );
- }
- function DocumentInspector({
- activeTab,
- onTab,
- selectedBase,
- selectedDocument,
- selectedDocumentResults,
- selectedDocumentChunks,
- lastIngest,
- }: {
- activeTab: string;
- onTab: (value: string) => void;
- selectedBase?: KnowledgeBase;
- selectedDocument?: KnowledgeDocument;
- selectedDocumentResults: SearchResult[];
- selectedDocumentChunks: KnowledgeChunk[];
- lastIngest?: KnowledgeDocumentIngestResponse;
- }) {
- const { t } = useTranslation();
- return (
- <Card className="min-w-0">
- <CardHeader>
- <CardTitle className="break-words">{selectedDocument ? demoText(selectedDocument.title, t) : t("knowledge.inspector")}</CardTitle>
- </CardHeader>
- <CardContent className="min-w-0">
- <Tabs
- value={activeTab}
- onChange={onTab}
- tabs={[
- {
- value: "overview",
- label: t("common.overview"),
- content: selectedDocument ? (
- <div className="space-y-4 text-sm">
- <div className="grid gap-3 sm:grid-cols-2">
- <Detail label={t("common.status")} value={t(`status.${selectedDocument.status}`, humanizeCode(selectedDocument.status))} />
- <Detail label={t("knowledge.source")} value={t(`knowledge.sourceLabels.${selectedDocument.sourceType}`, humanizeCode(selectedDocument.sourceType))} />
- <Detail label={t("common.created")} value={formatDateTime(selectedDocument.createdTime)} />
- <Detail label={t("knowledge.indexed")} value={selectedDocument.indexedTime ? formatDateTime(selectedDocument.indexedTime) : t("knowledge.pending")} />
- </div>
- <Detail label={t("knowledge.sourceUri")} value={selectedDocument.sourceUri ?? t("knowledge.notProvided")} />
- <Detail label={t("knowledge.contentHash")} value={selectedDocument.contentHash ?? t("knowledge.notAvailable")} />
- {lastIngest?.document.id === selectedDocument.id ? (
- <div className="rounded-md border border-green-500/25 bg-green-500/10 p-3 text-sm text-green-700 dark:text-green-200">
- {t("knowledge.lastIngestCreated", { count: lastIngest.chunks.length })}
- </div>
- ) : null}
- </div>
- ) : (
- <EmptyState icon={FileText} title={t("knowledge.selectDocument")} description={t("knowledge.documentDetailsAppear")} />
- ),
- },
- {
- value: "search",
- label: t("knowledge.search"),
- content: selectedDocumentResults.length ? (
- <div className="space-y-3">{selectedDocumentResults.map((result) => <SearchResultCard key={result.chunk.id} result={result} />)}</div>
- ) : (
- <EmptyState icon={Search} title={t("knowledge.noDocumentSearchResults")} description={t("knowledge.runQueryPlayground")} />
- ),
- },
- {
- value: "metadata",
- label: t("knowledge.propertiesTab"),
- content: <MetadataSummary metadata={selectedDocument?.metadata ?? selectedBase?.metadata ?? {}} />,
- },
- {
- value: "chunks",
- label: t("knowledge.chunks"),
- content: selectedDocumentChunks.length ? (
- <div className="space-y-3">
- {selectedDocumentChunks.map((chunk) => (
- <div key={chunk.id} className="rounded-md border border-border bg-muted/30 p-3">
- <div className="flex flex-wrap items-center justify-between gap-3">
- <span className="font-mono text-xs text-muted-foreground">{t("knowledge.chunk")} #{chunk.chunkIndex}</span>
- <span className="font-mono text-xs text-muted-foreground">{chunk.tokenCount} {t("knowledge.tokens")}</span>
- </div>
- <p className="mt-3 text-sm leading-6 text-muted-foreground">{chunk.contentText}</p>
- </div>
- ))}
- </div>
- ) : (
- <EmptyState icon={Layers3} title={t("knowledge.noChunksLoaded")} description={t("knowledge.runSearchOrIndex")} />
- ),
- },
- ]}
- />
- </CardContent>
- </Card>
- );
- }
- function EvaluationPage({ evalCases, onAdd, onRun }: { evalCases: EvalCase[]; onAdd: (queryText: string, expected: string) => void; onRun: (evalId: string) => void }) {
- const { t } = useTranslation();
- const [queryText, setQueryText] = React.useState("");
- const [expected, setExpected] = React.useState("");
- const avgRecall = average(evalCases.map((item) => item.recall));
- const avgPrecision = average(evalCases.map((item) => item.precision));
- function submit(event: React.FormEvent) {
- event.preventDefault();
- if (!queryText.trim()) return;
- onAdd(queryText.trim(), expected.trim() || t("knowledge.expectedCitation"));
- setQueryText("");
- setExpected("");
- }
- return (
- <div className="grid gap-6 2xl:grid-cols-[420px_minmax(0,1fr)]">
- <Card>
- <CardHeader>
- <CardTitle>{t("knowledge.goldenQuery")}</CardTitle>
- <p className="text-sm text-muted-foreground">{t("knowledge.buildEvaluationSet")}</p>
- </CardHeader>
- <CardContent>
- <form className="space-y-4" onSubmit={submit}>
- <Field label={t("knowledge.query")}><Textarea required value={queryText} onChange={(event) => setQueryText(event.target.value)} /></Field>
- <Field label={t("knowledge.expectedSource")}><Input value={expected} onChange={(event) => setExpected(event.target.value)} /></Field>
- <Button className="w-full"><ClipboardCheck className="h-4 w-4" /> {t("knowledge.addCase")}</Button>
- </form>
- <div className="mt-6 grid grid-cols-2 gap-3">
- <ScoreTile label={t("knowledge.avgRecall")} value={avgRecall} />
- <ScoreTile label={t("knowledge.avgPrecision")} value={avgPrecision} />
- </div>
- </CardContent>
- </Card>
- <Card>
- <CardHeader>
- <CardTitle>{t("knowledge.evaluationCases")}</CardTitle>
- </CardHeader>
- <CardContent className="space-y-3">
- {evalCases.map((item) => (
- <div key={item.id} className="rounded-md border border-border bg-muted/30 p-4">
- <div className="flex items-start justify-between gap-3">
- <div className="min-w-0">
- <p className="break-words text-sm font-medium">{formatEvalText(item.query, t)}</p>
- <p className="mt-1 break-words text-xs text-muted-foreground">{t("knowledge.expected")}: {formatEvalText(item.expected, t)}</p>
- </div>
- <span className="shrink-0"><StatusBadge status={item.status} /></span>
- </div>
- <div className="mt-4 grid gap-3 xl:grid-cols-[minmax(0,1fr)_minmax(0,1fr)_auto]">
- <BarMeter label={t("knowledge.recall")} value={item.recall} />
- <BarMeter label={t("knowledge.precision")} value={item.precision} />
- <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>
- </div>
- </div>
- ))}
- </CardContent>
- </Card>
- </div>
- );
- }
- function JobsPage({ jobs, onCreateReindex }: { jobs: KnowledgeJob[]; onCreateReindex: () => void }) {
- const { t } = useTranslation();
- return (
- <Card>
- <CardHeader>
- <div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
- <div>
- <CardTitle>{t("knowledge.indexJobs")}</CardTitle>
- <p className="text-sm text-muted-foreground">{t("knowledge.manageFrontendQueues")}</p>
- </div>
- <Button className="w-full sm:w-auto" onClick={onCreateReindex}><RefreshCw className="h-4 w-4" /> {t("knowledge.reindexBase")}</Button>
- </div>
- </CardHeader>
- <CardContent className="space-y-3">
- {jobs.map((job) => (
- <div key={job.id} className="rounded-md border border-border bg-muted/30 p-4">
- <div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
- <div className="min-w-0">
- <div className="flex flex-wrap items-center gap-2">
- <p className="break-words text-sm font-semibold">{formatKnowledgeJobType(job.type, t)}</p>
- <StatusBadge status={job.status} />
- </div>
- <p className="mt-1 break-words text-xs text-muted-foreground">{formatKnowledgeJobTarget(job.target, t)} / {formatDateTime(job.createdTime)}</p>
- </div>
- </div>
- <div className="mt-4">
- <BarMeter label={t("knowledge.progress")} value={job.progress / 100} />
- </div>
- </div>
- ))}
- </CardContent>
- </Card>
- );
- }
- function AnalyticsPage({ documents, results, jobs, evalCases }: { documents: KnowledgeDocument[]; results: SearchResult[]; jobs: KnowledgeJob[]; evalCases: EvalCase[] }) {
- const { t } = useTranslation();
- const failedJobs = jobs.filter((job) => job.status === "failed").length;
- const avgScore = average(results.map((result) => result.score));
- const indexedRatio = documents.length ? documents.filter((document) => document.status === "indexed").length / documents.length : 0;
- const passRatio = evalCases.length ? evalCases.filter((item) => item.status === "passed").length / evalCases.length : 0;
- return (
- <div className="space-y-6">
- <div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
- <MetricCard label={t("knowledge.indexedRatio")} value={`${Math.round(indexedRatio * 100)}%`} icon={Sparkles} />
- <MetricCard label={t("knowledge.avgScore")} value={avgScore ? avgScore.toFixed(2) : "0.00"} icon={Gauge} />
- <MetricCard label={t("knowledge.evalPass")} value={`${Math.round(passRatio * 100)}%`} icon={ClipboardCheck} />
- <MetricCard label={t("knowledge.failedJobs")} value={failedJobs} icon={BarChart3} />
- </div>
- <Card>
- <CardHeader>
- <CardTitle>{t("knowledge.qualitySignals")}</CardTitle>
- </CardHeader>
- <CardContent className="grid gap-4 md:grid-cols-2">
- <BarMeter label={t("knowledge.indexCoverage")} value={indexedRatio} />
- <BarMeter label={t("knowledge.citationConfidence")} value={avgScore || 0} />
- <BarMeter label={t("knowledge.evaluationPassRate")} value={passRatio} />
- <BarMeter label={t("knowledge.jobHealth")} value={jobs.length ? 1 - failedJobs / jobs.length : 1} />
- </CardContent>
- </Card>
- <Card>
- <CardHeader>
- <CardTitle>{t("knowledge.topQueries")}</CardTitle>
- </CardHeader>
- <CardContent className="grid gap-3 md:grid-cols-2">
- {["downloadInvoice", "refundPolicy", "billingContact", "planUpgrade"].map((item, index) => (
- <div key={item} className="rounded-md border border-border bg-muted/30 p-4">
- <p className="text-sm font-medium">{t(`knowledge.topQuery.${item}`)}</p>
- <p className="mt-1 font-mono text-xs text-muted-foreground">
- {t("knowledge.queryStats", { count: 42 - index * 7, rate: Math.round((0.91 - index * 0.06) * 100) })}
- </p>
- </div>
- ))}
- </CardContent>
- </Card>
- </div>
- );
- }
- function ToggleRow({ icon: Icon, title, description, checked, onChange }: { icon: LucideIcon; title: string; description: string; checked: boolean; onChange: (checked: boolean) => void }) {
- return (
- <label className="flex min-h-16 cursor-pointer items-start justify-between gap-4 rounded-md border border-border bg-muted/20 p-3">
- <span className="flex min-w-0 gap-3">
- <Icon className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" />
- <span className="min-w-0">
- <span className="block text-sm font-medium">{title}</span>
- <span className="mt-1 block text-xs leading-5 text-muted-foreground">{description}</span>
- </span>
- </span>
- <input type="checkbox" className="mt-1 h-5 w-5 shrink-0 accent-primary" checked={checked} onChange={(event) => onChange(event.target.checked)} />
- </label>
- );
- }
- function BarMeter({ label, value }: { label: string; value: number }) {
- const normalized = Math.max(0, Math.min(1, value || 0));
- return (
- <div>
- <div className="flex items-center justify-between gap-3">
- <p className="text-xs text-muted-foreground">{label}</p>
- <p className="font-mono text-xs text-foreground">{Math.round(normalized * 100)}%</p>
- </div>
- <div className="mt-2 h-2 overflow-hidden rounded-full bg-muted">
- <div className="h-full rounded-full bg-primary" style={{ width: `${normalized * 100}%` }} />
- </div>
- </div>
- );
- }
- function ScoreTile({ label, value }: { label: string; value: number }) {
- return (
- <div className="rounded-md border border-border bg-muted/30 p-3">
- <p className="text-xs text-muted-foreground">{label}</p>
- <p className="mt-1 font-mono text-xl font-semibold">{Math.round(value * 100)}%</p>
- </div>
- );
- }
- function average(values: number[]) {
- const useful = values.filter((value) => value > 0);
- return useful.length ? useful.reduce((sum, value) => sum + value, 0) / useful.length : 0;
- }
- function groupChunksByDocument(chunks: KnowledgeChunk[]) {
- return chunks.reduce<Record<string, KnowledgeChunk[]>>((groups, chunk) => {
- groups[chunk.documentId] = [...(groups[chunk.documentId] ?? []), chunk].sort((a, b) => a.chunkIndex - b.chunkIndex);
- return groups;
- }, {});
- }
- function KnowledgeCapabilityBoard({ compact = false }: { compact?: boolean }) {
- const { t } = useTranslation();
- return (
- <Card>
- <CardHeader>
- <div className="flex flex-col gap-2 lg:flex-row lg:items-center lg:justify-between">
- <div>
- <CardTitle>{t("knowledge.knowledgeCapabilityMap")}</CardTitle>
- <p className="mt-1 text-sm text-muted-foreground">
- {compact ? t("knowledge.moduleStatusAtGlance") : t("knowledge.fullRagSurface")}
- </p>
- </div>
- <div className="flex flex-wrap gap-2">
- <StateBadge state="live" />
- <StateBadge state="mock" />
- <StateBadge state="prototype" />
- </div>
- </div>
- </CardHeader>
- <CardContent>
- <div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
- {capabilityGroups.map((group) => (
- <div key={group.titleKey} className="rounded-md border border-border bg-muted/30 p-4">
- <div className="flex items-center gap-3">
- <span className="flex h-10 w-10 items-center justify-center rounded-md bg-primary/10 text-primary">
- <group.icon className="h-5 w-5" />
- </span>
- <h3 className="text-sm font-semibold">{t(group.titleKey)}</h3>
- </div>
- <div className="mt-4 space-y-2">
- {group.items.map((item) => (
- <div key={item.labelKey} className="flex items-start justify-between gap-3 text-sm">
- <span className="min-w-0 break-words text-muted-foreground">{t(item.labelKey)}</span>
- <StateBadge state={item.state} compact />
- </div>
- ))}
- </div>
- </div>
- ))}
- </div>
- </CardContent>
- </Card>
- );
- }
- function RetrievalSettingsPanel({
- config,
- models,
- onChange,
- }: {
- config: RetrievalConfig;
- models: ModelDefinition[];
- onChange: (config: RetrievalConfig) => void;
- }) {
- const { t } = useTranslation();
- const embeddingOptions = buildModelOptions(models, t("knowledge.autoSelect"), "embedding", t);
- const rerankOptions = buildModelOptions(models, t("knowledge.autoSelect"), "rerank", t);
- function update(patch: Partial<RetrievalConfig>) {
- onChange({ ...config, ...patch });
- }
- return (
- <Card>
- <CardHeader>
- <CardTitle>{t("knowledge.retrievalSettings")}</CardTitle>
- <p className="text-sm text-muted-foreground">{t("knowledge.tuneRetrievalBehavior")}</p>
- </CardHeader>
- <CardContent className="space-y-5">
- <div className="rounded-md border border-border bg-muted/30 p-4">
- <div className="flex items-center gap-2">
- <Settings2 className="h-4 w-4 text-primary" />
- <p className="text-sm font-semibold">{t("knowledge.modelSelection")}</p>
- </div>
- <div className="mt-4 grid gap-3 lg:grid-cols-3">
- <Field label={t("knowledge.retrievalMode")}>
- <Select value={config.retrievalMode} onChange={(event) => update({ retrievalMode: event.target.value })} options={[
- { value: "hybrid", label: t("knowledge.hybridRetrieval") },
- { value: "vector", label: t("knowledge.vectorOnly") },
- { value: "keyword", label: t("knowledge.keywordOnly") },
- ]} />
- </Field>
- <Field label={t("knowledge.embeddingModel")}>
- <Select value={normalizeSelectedModel(config.embeddingModelId, embeddingOptions)} onChange={(event) => update({ embeddingModelId: event.target.value })} options={embeddingOptions} />
- </Field>
- <Field label={t("knowledge.rerankModel")}>
- <Select value={normalizeSelectedModel(config.rerankModelId, rerankOptions)} onChange={(event) => update({ rerankModelId: event.target.value })} options={rerankOptions} />
- </Field>
- </div>
- {!models.length ? (
- <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">
- {t("knowledge.noConfiguredModels")}
- </p>
- ) : null}
- </div>
- <div className="rounded-md border border-border bg-muted/30 p-4">
- <div className="flex items-center gap-2">
- <FileSearch className="h-4 w-4 text-primary" />
- <p className="text-sm font-semibold">{t("knowledge.retrievalDefaults")}</p>
- </div>
- <div className="mt-4 grid gap-3 md:grid-cols-2 2xl:grid-cols-5">
- <Field label={t("knowledge.chunkSize")}><Input inputMode="numeric" value={config.chunkSize} onChange={(event) => update({ chunkSize: event.target.value })} /></Field>
- <Field label={t("knowledge.chunkOverlap")}><Input inputMode="numeric" value={config.chunkOverlap} onChange={(event) => update({ chunkOverlap: event.target.value })} /></Field>
- <Field label={t("knowledge.topK")}><Input inputMode="numeric" value={config.topK} onChange={(event) => update({ topK: event.target.value })} /></Field>
- <Field label={t("knowledge.candidatePool")}><Input inputMode="numeric" value={config.maxCandidates} onChange={(event) => update({ maxCandidates: event.target.value })} /></Field>
- <Field label={t("knowledge.minimumScore")}><Input inputMode="decimal" value={config.minScore} onChange={(event) => update({ minScore: event.target.value })} /></Field>
- </div>
- </div>
- <div className="grid gap-3 lg:grid-cols-3">
- <Field label={t("knowledge.keywordWeight")}><Input value={config.keywordWeight} onChange={(event) => update({ keywordWeight: event.target.value })} /></Field>
- <Field label={t("knowledge.vectorWeight")}><Input value={config.vectorWeight} onChange={(event) => update({ vectorWeight: event.target.value })} /></Field>
- <Field label={t("knowledge.rerankWeight")}><Input value={config.rerankWeight} onChange={(event) => update({ rerankWeight: event.target.value })} /></Field>
- </div>
- <div className="grid gap-3">
- <ToggleRow icon={SlidersHorizontal} title={t("knowledge.queryRewrite")} description={t("knowledge.expandShortQueries")} checked={config.queryRewrite} onChange={(checked) => update({ queryRewrite: checked })} />
- <ToggleRow icon={FileSearch} title={t("knowledge.requireCitations")} description={t("knowledge.treatUncitedAnswers")} checked={config.requireCitations} onChange={(checked) => update({ requireCitations: checked })} />
- </div>
- </CardContent>
- </Card>
- );
- }
- function buildModelOptions(models: ModelDefinition[], autoLabel: string, capability: "embedding" | "rerank" | undefined, t: ReturnType<typeof useTranslation>["t"]) {
- const matchedModels = capability ? models.filter((model) => modelMatchesCapability(model, capability)) : models;
- const usableModels = matchedModels.length ? matchedModels : models;
- return [
- { value: "auto", label: autoLabel },
- ...usableModels.map((model) => ({
- value: model.id,
- label: `${demoText(model.name, t)} (${model.model_name})`,
- })),
- ];
- }
- function modelMatchesCapability(model: ModelDefinition, capability: "embedding" | "rerank") {
- const text = `${model.name} ${model.model_name} ${model.description ?? ""} ${model.capabilities_json.join(" ")}`.toLowerCase();
- if (capability === "embedding") {
- return text.includes("embed") || text.includes("vector");
- }
- return text.includes("rerank") || text.includes("ranker") || text.includes("re-rank");
- }
- function normalizeSelectedModel(value: string, options: Array<{ value: string; label: string }>) {
- return options.some((option) => option.value === value) ? value : "auto";
- }
- function settingsPayloadToForm(payload: KnowledgeSettingsPayload): RetrievalConfig {
- return {
- retrievalMode: payload.retrievalMode,
- embeddingModelId: payload.embeddingModelId,
- rerankModelId: payload.rerankModelId,
- chunkSize: String(payload.chunkSize),
- chunkOverlap: String(payload.chunkOverlap),
- topK: String(payload.topK),
- minScore: String(payload.minScore),
- maxCandidates: String(payload.maxCandidates),
- keywordWeight: String(payload.keywordWeight),
- vectorWeight: String(payload.vectorWeight),
- rerankWeight: String(payload.rerankWeight),
- queryRewrite: payload.queryRewrite,
- requireCitations: payload.requireCitations,
- };
- }
- function formToSettingsPayload(config: RetrievalConfig, knowledgeBaseId: string): KnowledgeSettingsPayload {
- return {
- knowledgeBaseId,
- retrievalMode: config.retrievalMode,
- embeddingModelId: config.embeddingModelId,
- rerankModelId: config.rerankModelId,
- chunkSize: readInteger(config.chunkSize, 800),
- chunkOverlap: readInteger(config.chunkOverlap, 120),
- topK: readInteger(config.topK, 5),
- minScore: readNumber(config.minScore, 0),
- maxCandidates: readInteger(config.maxCandidates, 50),
- keywordWeight: readNumber(config.keywordWeight, 0.55),
- vectorWeight: readNumber(config.vectorWeight, 0.3),
- rerankWeight: readNumber(config.rerankWeight, 0.15),
- queryRewrite: config.queryRewrite,
- requireCitations: config.requireCitations,
- };
- }
- function readInteger(value: string, fallback: number) {
- const parsed = Number(value);
- return Number.isFinite(parsed) ? Math.max(0, Math.round(parsed)) : fallback;
- }
- function readNumber(value: string, fallback: number) {
- const parsed = Number(value);
- return Number.isFinite(parsed) ? parsed : fallback;
- }
- function StateBadge({ state, compact }: { state: string; compact?: boolean }) {
- const { t } = useTranslation();
- const styles = {
- live: "border-green-500/30 bg-green-500/10 text-green-700 dark:text-green-200",
- mock: "border-blue-500/30 bg-blue-500/10 text-blue-700 dark:text-blue-200",
- prototype: "border-blue-500/30 bg-blue-500/10 text-blue-700 dark:text-blue-200",
- } as const;
- const labels: Record<string, string> = {
- live: t("knowledge.state.live"),
- mock: compact ? t("knowledge.state.mock") : t("knowledge.state.mock"),
- prototype: compact ? t("knowledge.state.proto") : t("knowledge.state.prototype"),
- };
- const key = state as keyof typeof styles;
- return <Badge className={`${styles[key] ?? styles.prototype} shrink-0 whitespace-nowrap`}>{labels[state] ?? state}</Badge>;
- }
- function SearchResultCard({ result }: { result: SearchResult }) {
- const { t } = useTranslation();
- return (
- <div className="rounded-md border border-border bg-muted/30 p-3">
- <div className="flex items-start justify-between gap-3">
- <div className="min-w-0">
- <p className="truncate text-sm font-semibold">{demoText(result.document.title, t)}</p>
- <p className="mt-1 text-xs text-muted-foreground">{t("knowledge.chunk")} {result.chunk.chunkIndex} / {result.chunk.tokenCount} {t("knowledge.tokens")}</p>
- </div>
- <span className="shrink-0 rounded-md bg-primary/10 px-2 py-1 font-mono text-xs text-primary">{result.score.toFixed(3)}</span>
- </div>
- <p className="mt-3 break-words text-sm leading-6 text-muted-foreground">{result.chunk.contentText}</p>
- <ScoreBreakdown score={result.score} details={result.scoreDetails} />
- </div>
- );
- }
- function ScoreBreakdown({ score, details }: { score: number; details?: JSONObject }) {
- const { t } = useTranslation();
- const entries = getReadableEntries(details).slice(0, 4);
- return (
- <div className="mt-3 rounded-md border border-border bg-surface-elevated p-3">
- <div className="flex items-center justify-between gap-3">
- <p className="text-xs font-medium text-muted-foreground">{t("knowledge.scoreDetails")}</p>
- <span className="shrink-0 rounded-sm bg-primary/10 px-2 py-1 font-mono text-xs text-primary">{Math.round(score * 100)}%</span>
- </div>
- {entries.length ? (
- <div className="mt-3 grid gap-2 sm:grid-cols-2">
- {entries.map(([key, value]) => (
- <Detail key={key} label={formatPropertyLabel(key, t)} value={formatPropertyValue(value, t)} />
- ))}
- </div>
- ) : (
- <p className="mt-2 text-xs text-muted-foreground">{t("knowledge.noExtraScoringSignals")}</p>
- )}
- </div>
- );
- }
- function MetadataSummary({ metadata }: { metadata?: JSONObject | null }) {
- const { t } = useTranslation();
- const entries = getReadableEntries(metadata);
- if (!entries.length) {
- return <EmptyState icon={FileSearch} title={t("knowledge.noProperties")} description={t("knowledge.noPropertiesDescription")} />;
- }
- return (
- <div className="grid gap-3 sm:grid-cols-2">
- {entries.map(([key, value]) => (
- <div key={key} className="rounded-md border border-border bg-muted/30 p-3">
- <p className="text-xs text-muted-foreground">{formatPropertyLabel(key, t)}</p>
- <p className="mt-1 break-words text-sm text-foreground">{formatPropertyValue(value, t)}</p>
- </div>
- ))}
- </div>
- );
- }
- function CreateKnowledgeBaseDialog({ open, onOpenChange, onCreated }: { open: boolean; onOpenChange: (open: boolean) => void; onCreated: () => void }) {
- const { t } = useTranslation();
- const [form, setForm] = React.useState({ name: "", description: "" });
- const [error, setError] = React.useState<string>();
- const [submitting, setSubmitting] = React.useState(false);
- async function submit(event: React.FormEvent) {
- event.preventDefault();
- setError(undefined);
- setSubmitting(true);
- try {
- await createKnowledgeBase({ name: form.name.trim(), description: form.description.trim() || null, metadata: {} });
- toast.success(t("knowledge.knowledgeBaseCreated"));
- onOpenChange(false);
- setForm({ name: "", description: "" });
- onCreated();
- } finally {
- setSubmitting(false);
- }
- }
- return (
- <Dialog open={open} onOpenChange={onOpenChange} title={t("knowledge.createKnowledgeBase")} className="max-w-2xl">
- <form className="space-y-4" onSubmit={submit}>
- {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}
- <Field label={t("common.name")}><Input required value={form.name} onChange={(event) => setForm({ ...form, name: event.target.value })} /></Field>
- <Field label={t("common.description")}><Textarea value={form.description} onChange={(event) => setForm({ ...form, description: event.target.value })} /></Field>
- <div className="flex justify-end gap-2">
- <Button type="button" variant="ghost" onClick={() => onOpenChange(false)}>{t("common.cancel")}</Button>
- <Button disabled={submitting}>{submitting ? t("common.creating") : t("common.create")}</Button>
- </div>
- </form>
- </Dialog>
- );
- }
- function CreateKnowledgeDocumentDialog({
- open,
- onOpenChange,
- knowledgeBaseId,
- knowledgeBaseName,
- onCreated,
- }: {
- open: boolean;
- onOpenChange: (open: boolean) => void;
- knowledgeBaseId?: string;
- knowledgeBaseName?: string;
- onCreated: (ingest: KnowledgeDocumentIngestResponse) => void;
- }) {
- const { t } = useTranslation();
- const [form, setForm] = React.useState({
- title: "",
- sourceType: "text",
- sourceUri: "",
- contentText: "",
- chunkSize: "800",
- chunkOverlap: "120",
- });
- const [parsePreview, setParsePreview] = React.useState<KnowledgeDocumentParseResponse>();
- const [error, setError] = React.useState<string>();
- const [parsing, setParsing] = React.useState(false);
- const [submitting, setSubmitting] = React.useState(false);
- async function previewParse() {
- setError(undefined);
- setParsing(true);
- try {
- const parsed = await parseKnowledgeDocument({
- sourceType: form.sourceType,
- sourceUri: form.sourceUri.trim() || null,
- contentText: form.contentText,
- });
- setParsePreview(parsed);
- toast.success(t("knowledge.parsePreviewReady"));
- } catch (err) {
- setError(translateApiError(err));
- } finally {
- setParsing(false);
- }
- }
- async function submit(event: React.FormEvent) {
- event.preventDefault();
- if (!knowledgeBaseId) return;
- setError(undefined);
- setSubmitting(true);
- try {
- const ingest = await createKnowledgeDocument({
- knowledgeBaseId,
- title: form.title.trim(),
- sourceType: form.sourceType,
- sourceUri: form.sourceUri.trim() || null,
- contentText: form.contentText,
- chunkSize: Number(form.chunkSize) || null,
- chunkOverlap: Number(form.chunkOverlap) || null,
- metadata: {},
- });
- toast.success(ingest.queued ? t("knowledge.documentQueued") : t("knowledge.documentIndexedWithChunks", { count: ingest.chunks.length }));
- onOpenChange(false);
- setForm({ title: "", sourceType: "text", sourceUri: "", contentText: "", chunkSize: "800", chunkOverlap: "120" });
- setParsePreview(undefined);
- onCreated(ingest);
- } catch (err) {
- setError(translateApiError(err));
- } finally {
- setSubmitting(false);
- }
- }
- return (
- <Dialog open={open} onOpenChange={onOpenChange} title={t("knowledge.addDocument")} className="max-w-4xl">
- <form className="space-y-4" onSubmit={submit}>
- {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}
- <div className="rounded-md border border-primary/20 bg-primary/10 p-3">
- <p className="text-sm font-medium">{t("knowledge.targetBase")}: {knowledgeBaseName ?? t("knowledge.noBaseSelected")}</p>
- <p className="mt-1 text-xs leading-5 text-muted-foreground">
- {t("knowledge.documentTargetHint")}
- </p>
- </div>
- <div className="grid gap-3 lg:grid-cols-2">
- <Field label={t("knowledge.addDocumentTitle")}><Input required value={form.title} onChange={(event) => setForm({ ...form, title: event.target.value })} /></Field>
- <Field label={t("knowledge.sourceType")}>
- <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}`) }))} />
- </Field>
- </div>
- <Field label={t("knowledge.sourceUri")}><Input value={form.sourceUri} onChange={(event) => setForm({ ...form, sourceUri: event.target.value })} placeholder="https://docs.example.com/page" /></Field>
- <Field label={t("knowledge.content")}><Textarea required className="min-h-40" value={form.contentText} onChange={(event) => setForm({ ...form, contentText: event.target.value })} /></Field>
- <div className="grid gap-3 lg:grid-cols-2">
- <Field label={t("knowledge.chunkSize")}><Input inputMode="numeric" value={form.chunkSize} onChange={(event) => setForm({ ...form, chunkSize: event.target.value })} /></Field>
- <Field label={t("knowledge.chunkOverlap")}><Input inputMode="numeric" value={form.chunkOverlap} onChange={(event) => setForm({ ...form, chunkOverlap: event.target.value })} /></Field>
- </div>
- {parsePreview ? (
- <div className="rounded-md border border-border bg-muted/30 p-3">
- <p className="text-sm font-medium">{t("knowledge.parsePreview")}</p>
- <p className="mt-1 line-clamp-3 text-sm text-muted-foreground">{parsePreview.contentText}</p>
- <MetadataSummary metadata={parsePreview.metadata} />
- </div>
- ) : null}
- <div className="flex flex-wrap justify-end gap-2">
- <Button type="button" variant="ghost" onClick={() => onOpenChange(false)}>{t("common.cancel")}</Button>
- <Button type="button" variant="secondary" disabled={parsing || !form.contentText.trim()} onClick={() => void previewParse()}>
- {parsing ? t("knowledge.parsing") : t("knowledge.previewParse")}
- </Button>
- <Button disabled={submitting || !knowledgeBaseId}>{submitting ? t("knowledge.indexing") : t("knowledge.indexDocument")}</Button>
- </div>
- </form>
- </Dialog>
- );
- }
- function Detail({ label, value }: { label: string; value: string }) {
- return (
- <div className="min-w-0">
- <p className="text-xs text-muted-foreground">{label}</p>
- <p className="mt-1 break-words font-mono text-xs text-foreground">{value}</p>
- </div>
- );
- }
- function Field({ label, children }: { label: string; children: React.ReactNode }) {
- return <label className="block space-y-2 text-sm"><span className="text-muted-foreground">{label}</span>{children}</label>;
- }
- function formatKnowledgeJobType(value: string, t: ReturnType<typeof useTranslation>["t"]) {
- const known: Record<string, string> = {
- "Sitemap Sync": "sitemapSync",
- "Re-index": "reindex",
- "PDF Batch": "pdfBatch",
- index: "index",
- reindex: "reindex",
- };
- const key = known[value];
- return key ? t(`knowledge.jobTypes.${key}`) : value;
- }
- function toKnowledgeJob(job: KnowledgeIndexJob): KnowledgeJob {
- return {
- id: job.jobId,
- type: job.action,
- target: job.documentTitle || job.documentId,
- status: job.status,
- progress: job.progress,
- createdTime: job.queuedTime || job.startedTime || job.completedTime || new Date().toISOString(),
- workerKey: job.workerKey,
- errorMessage: job.errorMessage,
- };
- }
- function formatKnowledgeJobTarget(value: string, t: ReturnType<typeof useTranslation>["t"]) {
- const known: Record<string, string> = {
- "Product Docs": "productDocs",
- "Policy Pack": "policyPack",
- };
- const key = known[value];
- return key ? t(`knowledge.jobTargets.${key}`) : value;
- }
- function formatEvalText(value: string, t: ReturnType<typeof useTranslation>["t"]) {
- const known: Record<string, string> = {
- "Where can customers download invoices?": "downloadInvoices",
- "Billing and Invoice FAQ": "billingInvoiceFaq",
- "How do refunds affect annual plans?": "annualPlanRefunds",
- "Refund policy": "refundPolicy",
- };
- const key = known[value];
- return key ? t(`knowledge.evalSamples.${key}`) : value;
- }
- function humanizeCode(value: string) {
- return value
- .replace(/_/g, " ")
- .replace(/\b\w/g, (letter) => letter.toUpperCase());
- }
- function getReadableEntries(metadata?: JSONObject | null): Array<[string, unknown]> {
- return Object.entries(metadata ?? {}).filter(([, value]) => value !== undefined && value !== null && value !== "");
- }
- function formatPropertyLabel(value: string, t?: ReturnType<typeof useTranslation>["t"]) {
- const normalized = value.replace(/_json$/i, "");
- const fallback = normalized
- .replace(/_json$/i, "")
- .replace(/_/g, " ")
- .replace(/\b\w/g, (letter) => letter.toUpperCase());
- return t ? t(`knowledge.propertyLabels.${normalized}`, fallback) : fallback;
- }
- function formatPropertyValue(value: unknown, t?: ReturnType<typeof useTranslation>["t"]): string {
- if (Array.isArray(value)) {
- return value.map((item) => formatPropertyValue(item, t)).join(", ");
- }
- if (typeof value === "object" && value !== null) {
- const summary = Object.entries(value)
- .slice(0, 3)
- .map(([key, item]) => `${formatPropertyLabel(key, t)}: ${formatPropertyValue(item, t)}`)
- .join("; ");
- return summary || (t ? t("common.configured") : "Configured");
- }
- if (typeof value === "boolean") {
- return value ? (t ? t("common.yes") : "Yes") : (t ? t("common.no") : "No");
- }
- return String(value);
- }
|