"use client"; import { useEffect, useState, type ReactNode } from "react"; import { useTranslation } from "react-i18next"; import { AlertTriangle, Check, FolderOpen, FolderSearch, Link2, Loader2, Plus, Server, } from "lucide-react"; import Modal from "@/components/common/Modal"; import { probeLightRagServer, probeLinkedFolder, type KnowledgeUploadPolicy, type LightRagServerProbe, type LinkedFolderProbe, type RagProviderSummary, } from "@/lib/knowledge-api"; import { validateFiles } from "@/lib/knowledge-helpers"; import FileDropZone from "./FileDropZone"; const PAGEINDEX_FORMATS = [".pdf", ".md", ".markdown"]; const OBSIDIAN_SOURCE = "obsidian"; const LIGHTRAG_SERVER_PROVIDER = "lightrag-server"; const EXAMPLE_INDEX_PATH = "/Users/you/knowledge_bases/my-kb"; const EXAMPLE_VAULT_PATH = "/Users/you/Documents/MyVault"; const EXAMPLE_SERVER_URL = "http://localhost:9621"; type Mode = "new" | "link"; interface CreateKbModalProps { isOpen: boolean; onClose: () => void; providers: RagProviderSummary[]; uploadPolicy: KnowledgeUploadPolicy; onCreate: (params: { name: string; provider: string; files: File[]; }) => Promise; /** Link a pre-built engine index folder in place (no copy, no re-index). */ onConnectLinkedFolder: (params: { name: string; folderPath: string; provider: string; }) => Promise; /** Connect a live Obsidian vault (no index). */ onConnectObsidian: (params: { name: string; vaultPath: string; }) => Promise; /** Connect an external LightRAG server (retrieval only, no local index). */ onConnectLightRagServer: (params: { name: string; serverUrl: string; apiKey?: string; mode?: string; }) => Promise; /** Open the RAG pipeline settings (to add a missing API key). */ onConfigureProvider?: () => void; /** Open straight into a given mode (e.g. "link" from the Obsidian card). */ initialMode?: Mode; /** Pre-select a link source (engine id or "obsidian") when opening in link mode. */ initialSource?: string; } export default function CreateKbModal({ isOpen, onClose, providers, uploadPolicy, onCreate, onConnectLinkedFolder, onConnectObsidian, onConnectLightRagServer, onConfigureProvider, initialMode = "new", initialSource, }: CreateKbModalProps) { const { t } = useTranslation(); const [mode, setMode] = useState("new"); const [name, setName] = useState(""); const [provider, setProvider] = useState("llamaindex"); const [files, setFiles] = useState([]); // Link mode: the source is either an engine id or the Obsidian sentinel. const [linkSource, setLinkSource] = useState(OBSIDIAN_SOURCE); const [folderPath, setFolderPath] = useState(""); const [probe, setProbe] = useState(null); const [probing, setProbing] = useState(false); // LightRAG Server engine (new mode): a connection instead of an upload. const [serverUrl, setServerUrl] = useState(""); const [apiKey, setApiKey] = useState(""); const [serverMode, setServerMode] = useState(""); const [serverProbe, setServerProbe] = useState( null, ); const [serverProbing, setServerProbing] = useState(false); const [submitting, setSubmitting] = useState(false); const [error, setError] = useState(null); const firstLinkable = providers.find((p) => p.linkable)?.id; useEffect(() => { if (!isOpen) return; setMode(initialMode); setName(""); setFiles([]); setError(null); setProvider(initialSource || providers[0]?.id || "llamaindex"); setLinkSource(initialSource || firstLinkable || OBSIDIAN_SOURCE); setFolderPath(""); setProbe(null); setProbing(false); setServerUrl(""); setApiKey(""); setServerMode(""); setServerProbe(null); setServerProbing(false); }, [isOpen, providers, firstLinkable, initialMode, initialSource]); // A fresh path / source invalidates a stale probe verdict. useEffect(() => { setProbe(null); }, [folderPath, linkSource]); // A fresh URL / key invalidates a stale server connection test. useEffect(() => { setServerProbe(null); }, [serverUrl, apiKey]); // ---- New mode (build a fresh index) ---------------------------------- const activeProvider = providers.find((p) => p.id === provider); const providerNeedsKey = !!activeProvider?.requires_api_key && activeProvider?.configured === false; const providerUnavailable = activeProvider?.configured === false; const isPageIndex = provider === "pageindex"; const isLightRagServer = provider === LIGHTRAG_SERVER_PROVIDER; const serverModeOptions = activeProvider?.modes ?? []; const effectiveServerMode = serverMode || activeProvider?.default_mode || serverModeOptions[0] || ""; const policyForProvider: KnowledgeUploadPolicy = isPageIndex ? { ...uploadPolicy, extensions: PAGEINDEX_FORMATS, accept: PAGEINDEX_FORMATS.join(","), } : uploadPolicy; const selection = validateFiles(files, policyForProvider, t); // ---- Link mode (mount an existing folder) ---------------------------- const linkIsObsidian = linkSource === OBSIDIAN_SOURCE; const trimmed = name.trim(); const trimmedPath = folderPath.trim(); const trimmedServerUrl = serverUrl.trim(); const canSubmit = (() => { if (submitting) return false; if (!trimmed) return false; if (mode === "new") { if (isLightRagServer) { // The connection must pass the test before a KB is bound to it. return !!trimmedServerUrl && !!serverProbe?.ok; } return !providerUnavailable && selection.validFiles.length > 0; } if (!trimmedPath) return false; if (linkIsObsidian) return true; // An engine index must pass the probe before it can be linked. return !!probe?.ok; })(); const handleProbe = async () => { if (!trimmedPath || linkIsObsidian || probing) return; setProbing(true); setError(null); try { const result = await probeLinkedFolder({ folderPath: trimmedPath, provider: linkSource, }); setProbe(result); } catch (err) { setError(err instanceof Error ? err.message : String(err)); } finally { setProbing(false); } }; const handleTestServer = async () => { if (!trimmedServerUrl || serverProbing) return; setServerProbing(true); setError(null); try { const result = await probeLightRagServer({ serverUrl: trimmedServerUrl, apiKey: apiKey.trim(), }); setServerProbe(result); } catch (err) { setError(err instanceof Error ? err.message : String(err)); } finally { setServerProbing(false); } }; const handleSubmit = async () => { if (!canSubmit) return; setSubmitting(true); setError(null); try { if (mode === "new") { if (isLightRagServer) { await onConnectLightRagServer({ name: trimmed, serverUrl: trimmedServerUrl, apiKey: apiKey.trim(), mode: effectiveServerMode, }); } else { await onCreate({ name: trimmed, provider, files: selection.validFiles, }); } } else if (linkIsObsidian) { await onConnectObsidian({ name: trimmed, vaultPath: trimmedPath }); } else { await onConnectLinkedFolder({ name: trimmed, folderPath: trimmedPath, provider: linkSource, }); } onClose(); } catch (err) { setError(err instanceof Error ? err.message : String(err)); } finally { setSubmitting(false); } }; const submitLabel = mode === "new" ? isLightRagServer ? t("Connect") : t("Create") : linkIsObsidian ? t("Connect") : t("Link"); return ( {} : onClose} title={t("Create knowledge base")} titleIcon={} width="lg" closeOnBackdrop={!submitting} closeOnEscape={!submitting} footer={
} >
{/* New vs. link existing */}
setName(event.target.value)} autoFocus disabled={submitting} placeholder={t("e.g. project-papers")} className="w-full rounded-lg border border-[var(--border)] bg-[var(--background)] px-3 py-2 text-[13px] text-[var(--foreground)] outline-none transition-colors focus:border-[var(--foreground)]/25 disabled:opacity-50" />
{mode === "new" ? ( ) : null } t={t} /> ) : ( )} {error && (
              {error}
            
)}
); } type TFn = (key: string, options?: Record) => string; function ModeToggle({ mode, onChange, disabled, t, }: { mode: Mode; onChange: (mode: Mode) => void; disabled: boolean; t: TFn; }) { const options: { id: Mode; label: string; hint: string; icon: typeof Plus; }[] = [ { id: "new", label: t("Create new"), hint: t("Upload documents and build a fresh index."), icon: Plus, }, { id: "link", label: t("Link existing"), hint: t( "Reuse an index you already built — read in place, no upload or re-index.", ), icon: Link2, }, ]; return (
{options.map((opt) => { const selected = mode === opt.id; const Icon = opt.icon; return ( ); })}
); } function NewModeFields({ providers, provider, setProvider, submitting, providerUnavailable, providerNeedsKey, onConfigureProvider, isPageIndex, files, setFiles, policyForProvider, connectionForm, t, }: { providers: RagProviderSummary[]; provider: string; setProvider: (id: string) => void; submitting: boolean; providerUnavailable: boolean; providerNeedsKey: boolean; onConfigureProvider?: () => void; isPageIndex: boolean; files: File[]; setFiles: (files: File[]) => void; policyForProvider: KnowledgeUploadPolicy; /** When set, replaces the upload step (e.g. a server connection form). */ connectionForm?: ReactNode; t: TFn; }) { return ( <>
{providers.map((p) => { const selected = provider === p.id; const needsKey = !!p.requires_api_key && p.configured === false; const unavailable = p.configured === false && !p.requires_api_key; return ( ); })}
{providerUnavailable && (
{providerNeedsKey ? t( "This engine needs an API key. Configure it before creating.", ) : t( "This engine isn't installed on the server. Install it before creating.", )} {providerNeedsKey && onConfigureProvider && ( )}
)}
{connectionForm ?? (
)} ); } function LightRagServerFields({ serverUrl, setServerUrl, apiKey, setApiKey, serverMode, setServerMode, modeOptions, submitting, probing, probe, onTest, t, }: { serverUrl: string; setServerUrl: (value: string) => void; apiKey: string; setApiKey: (value: string) => void; serverMode: string; setServerMode: (value: string) => void; modeOptions: string[]; submitting: boolean; probing: boolean; probe: LightRagServerProbe | null; onTest: () => void; t: TFn; }) { return (
setServerUrl(event.target.value)} disabled={submitting} placeholder={EXAMPLE_SERVER_URL} className="w-full rounded-lg border border-[var(--border)] bg-[var(--background)] px-3 py-2 font-mono text-[12.5px] text-[var(--foreground)] outline-none transition-colors focus:border-[var(--foreground)]/25 disabled:opacity-50" />

{t( "The base URL of your running LightRAG server. Documents are indexed there — nothing is uploaded or copied.", )}

setApiKey(event.target.value)} disabled={submitting} type="password" autoComplete="off" placeholder={t("Only if your server requires one")} className="w-full rounded-lg border border-[var(--border)] bg-[var(--background)] px-3 py-2 text-[12.5px] text-[var(--foreground)] outline-none transition-colors focus:border-[var(--foreground)]/25 disabled:opacity-50" />
{modeOptions.length > 0 && (
)} {probe && }
); } function ServerProbeVerdict({ probe, t, }: { probe: LightRagServerProbe; t: TFn; }) { if (!probe.ok) { return (
{t("Could not connect")} {probe.error &&

{probe.error}

}
); } return (
{t("Connected to LightRAG server")}
{probe.core_version && ( {t("Core {{version}}", { version: probe.core_version })} )} {probe.auth_required ? t("API key accepted") : t("Open access")}
); } function LinkModeFields({ providers, linkSource, setLinkSource, linkIsObsidian, folderPath, setFolderPath, submitting, probing, probe, onProbe, t, }: { providers: RagProviderSummary[]; linkSource: string; setLinkSource: (id: string) => void; linkIsObsidian: boolean; folderPath: string; setFolderPath: (value: string) => void; submitting: boolean; probing: boolean; probe: LinkedFolderProbe | null; onProbe: () => void; t: TFn; }) { return ( <>
{providers.map((p) => { const selected = !linkIsObsidian && linkSource === p.id; const disabled = submitting || !p.linkable; return ( ); })} {/* Obsidian — a live vault, no index. */}
setFolderPath(event.target.value)} disabled={submitting} placeholder={ linkIsObsidian ? EXAMPLE_VAULT_PATH : EXAMPLE_INDEX_PATH } className="w-full rounded-lg border border-[var(--border)] bg-[var(--background)] px-3 py-2 font-mono text-[12.5px] text-[var(--foreground)] outline-none transition-colors focus:border-[var(--foreground)]/25 disabled:opacity-50" /> {!linkIsObsidian && ( )}

{linkIsObsidian ? t("The absolute path to the vault folder on this machine.") : t( "The absolute path to a knowledge base folder on this machine — nothing is copied.", )}

{!linkIsObsidian && probe && } ); } function ProbeVerdict({ probe, t }: { probe: LinkedFolderProbe; t: TFn }) { if (!probe.ok) { return (
{t("This folder can't be linked")} {probe.error &&

{probe.error}

}
); } const compatible = probe.embedding.compatible; return (
{t("Ready index found")}
{probe.version && {probe.version}} {probe.doc_count != null && ( {t("{{count}} documents", { count: probe.doc_count })} )} {compatible === true && ( {t("Embedding model matches")} )}
{probe.warnings.map((warning, index) => (

{warning}

))}
); }