Files
wehub-resource-sync e4dcfc49aa
Tests / Import Check (Python 3.13) (push) Has been cancelled
Tests / Import Check (Python 3.14) (push) Has been cancelled
Tests / Python Tests (Python 3.11) (push) Has been cancelled
Tests / Python Tests (Python 3.12) (push) Has been cancelled
Tests / Python Tests (Python 3.14) (push) Has been cancelled
Tests / Test Summary (push) Has been cancelled
Tests / Lint and Format (push) Has been cancelled
Tests / Web Node Tests (push) Has been cancelled
Tests / Import Check (Python 3.11) (push) Has been cancelled
Tests / Import Check (Python 3.12) (push) Has been cancelled
Tests / Python Tests (Python 3.13) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:00:43 +08:00

900 lines
30 KiB
TypeScript

"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<void>;
/** Link a pre-built engine index folder in place (no copy, no re-index). */
onConnectLinkedFolder: (params: {
name: string;
folderPath: string;
provider: string;
}) => Promise<void>;
/** Connect a live Obsidian vault (no index). */
onConnectObsidian: (params: {
name: string;
vaultPath: string;
}) => Promise<void>;
/** Connect an external LightRAG server (retrieval only, no local index). */
onConnectLightRagServer: (params: {
name: string;
serverUrl: string;
apiKey?: string;
mode?: string;
}) => Promise<void>;
/** 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<Mode>("new");
const [name, setName] = useState("");
const [provider, setProvider] = useState("llamaindex");
const [files, setFiles] = useState<File[]>([]);
// 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<LinkedFolderProbe | null>(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<LightRagServerProbe | null>(
null,
);
const [serverProbing, setServerProbing] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(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 (
<Modal
isOpen={isOpen}
onClose={submitting ? () => {} : onClose}
title={t("Create knowledge base")}
titleIcon={<Plus size={16} />}
width="lg"
closeOnBackdrop={!submitting}
closeOnEscape={!submitting}
footer={
<div className="flex items-center justify-end gap-2">
<button
type="button"
onClick={onClose}
disabled={submitting}
className="rounded-md px-3 py-1.5 text-[12.5px] font-medium text-[var(--muted-foreground)] transition-colors hover:bg-[var(--muted)] hover:text-[var(--foreground)] disabled:opacity-40"
>
{t("Cancel")}
</button>
<button
type="button"
onClick={() => void handleSubmit()}
disabled={!canSubmit}
className="inline-flex items-center gap-1.5 rounded-md bg-[var(--primary)] px-3.5 py-1.5 text-[12.5px] font-medium text-[var(--primary-foreground)] transition-opacity hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-40"
>
{submitting ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : mode === "new" && !isLightRagServer ? (
<Plus size={14} />
) : (
<Link2 size={14} />
)}
{submitLabel}
</button>
</div>
}
>
<div className="space-y-4 px-5 py-4">
{/* New vs. link existing */}
<ModeToggle
mode={mode}
onChange={setMode}
disabled={submitting}
t={t}
/>
<div>
<label className="mb-1 block text-[11px] font-medium uppercase tracking-wide text-[var(--muted-foreground)]">
{t("Knowledge base name")}
</label>
<input
value={name}
onChange={(event) => 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"
/>
</div>
{mode === "new" ? (
<NewModeFields
providers={providers}
provider={provider}
setProvider={setProvider}
submitting={submitting}
providerUnavailable={providerUnavailable}
providerNeedsKey={providerNeedsKey}
onConfigureProvider={onConfigureProvider}
isPageIndex={isPageIndex}
files={files}
setFiles={setFiles}
policyForProvider={policyForProvider}
connectionForm={
isLightRagServer ? (
<LightRagServerFields
serverUrl={serverUrl}
setServerUrl={setServerUrl}
apiKey={apiKey}
setApiKey={setApiKey}
serverMode={effectiveServerMode}
setServerMode={setServerMode}
modeOptions={serverModeOptions}
submitting={submitting}
probing={serverProbing}
probe={serverProbe}
onTest={handleTestServer}
t={t}
/>
) : null
}
t={t}
/>
) : (
<LinkModeFields
providers={providers}
linkSource={linkSource}
setLinkSource={setLinkSource}
linkIsObsidian={linkIsObsidian}
folderPath={folderPath}
setFolderPath={setFolderPath}
submitting={submitting}
probing={probing}
probe={probe}
onProbe={handleProbe}
t={t}
/>
)}
{error && (
<div className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-[12px] text-red-700 dark:border-red-900 dark:bg-red-950/30 dark:text-red-300">
<pre className="whitespace-pre-wrap break-words font-mono text-[11px] leading-relaxed">
{error}
</pre>
</div>
)}
</div>
</Modal>
);
}
type TFn = (key: string, options?: Record<string, unknown>) => 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 (
<div className="grid grid-cols-2 gap-2">
{options.map((opt) => {
const selected = mode === opt.id;
const Icon = opt.icon;
return (
<button
key={opt.id}
type="button"
disabled={disabled}
onClick={() => onChange(opt.id)}
className={`flex flex-col gap-1 rounded-2xl border p-3 text-left transition-colors disabled:opacity-50 ${
selected
? "border-[var(--primary)] bg-[var(--primary)]/5"
: "border-[var(--border)] hover:border-[var(--ring)]"
}`}
>
<span className="flex items-center gap-1.5 text-[13px] font-medium text-[var(--foreground)]">
<Icon className="h-3.5 w-3.5" />
{opt.label}
</span>
<span className="text-[11px] leading-snug text-[var(--muted-foreground)]">
{opt.hint}
</span>
</button>
);
})}
</div>
);
}
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 (
<>
<div>
<label className="mb-1.5 block text-[11px] font-medium uppercase tracking-wide text-[var(--muted-foreground)]">
{t("Index engine")}
</label>
<div className="grid gap-2 sm:grid-cols-2">
{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 (
<button
key={p.id}
type="button"
disabled={submitting}
onClick={() => setProvider(p.id)}
className={`group flex flex-col gap-1 rounded-2xl border p-3 text-left transition-colors disabled:opacity-50 ${
selected
? "border-[var(--primary)] bg-[var(--primary)]/5"
: "border-[var(--border)] hover:border-[var(--ring)]"
}`}
>
<div className="flex items-center justify-between gap-2">
<span className="text-[13px] font-medium text-[var(--foreground)]">
{p.name}
</span>
{needsKey ? (
<span className="inline-flex items-center gap-1 rounded-full bg-amber-100 px-1.5 py-0.5 text-[10px] font-medium text-amber-700 dark:bg-amber-950/30 dark:text-amber-300">
{t("Needs key")}
</span>
) : unavailable ? (
<span className="inline-flex items-center gap-1 rounded-full bg-[var(--muted)] px-1.5 py-0.5 text-[10px] font-medium text-[var(--muted-foreground)]">
{t("Not installed")}
</span>
) : selected ? (
<Check className="h-3.5 w-3.5 text-[var(--primary)]" />
) : null}
</div>
<span className="text-[11.5px] leading-snug text-[var(--muted-foreground)]">
{p.description}
</span>
</button>
);
})}
</div>
{providerUnavailable && (
<div className="mt-2 flex items-center justify-between gap-2 rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-[12px] text-amber-800 dark:border-amber-900 dark:bg-amber-950/30 dark:text-amber-200">
<span className="flex items-center gap-1.5">
<AlertTriangle className="h-3.5 w-3.5 shrink-0" />
{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.",
)}
</span>
{providerNeedsKey && onConfigureProvider && (
<button
type="button"
onClick={onConfigureProvider}
className="shrink-0 rounded-md px-2 py-1 text-[11.5px] font-medium text-amber-900 underline-offset-2 hover:underline dark:text-amber-100"
>
{t("Configure")}
</button>
)}
</div>
)}
</div>
{connectionForm ?? (
<div>
<label className="mb-2 block text-[11px] font-medium uppercase tracking-wide text-[var(--muted-foreground)]">
{t("Initial documents")}
{isPageIndex && (
<span className="ml-2 normal-case tracking-normal text-[var(--muted-foreground)]/80">
· {t("PDF and Markdown only")}
</span>
)}
</label>
<FileDropZone
files={files}
onChange={setFiles}
uploadPolicy={policyForProvider}
disabled={submitting}
/>
</div>
)}
</>
);
}
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 (
<div className="space-y-3">
<div>
<label className="mb-1 block text-[11px] font-medium uppercase tracking-wide text-[var(--muted-foreground)]">
{t("Server URL")}
</label>
<div className="flex gap-2">
<input
value={serverUrl}
onChange={(event) => 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"
/>
<button
type="button"
onClick={onTest}
disabled={submitting || probing || serverUrl.trim().length === 0}
className="inline-flex shrink-0 items-center gap-1.5 rounded-lg border border-[var(--border)] px-3 text-[12px] font-medium text-[var(--foreground)] transition-colors hover:border-[var(--ring)] disabled:cursor-not-allowed disabled:opacity-40"
>
{probing ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Server className="h-3.5 w-3.5" />
)}
{t("Test connection")}
</button>
</div>
<p className="mt-1 text-[11px] text-[var(--muted-foreground)]">
{t(
"The base URL of your running LightRAG server. Documents are indexed there — nothing is uploaded or copied.",
)}
</p>
</div>
<div>
<label className="mb-1 block text-[11px] font-medium uppercase tracking-wide text-[var(--muted-foreground)]">
{t("API key")}
<span className="ml-2 normal-case tracking-normal text-[var(--muted-foreground)]/80">
· {t("optional")}
</span>
</label>
<input
value={apiKey}
onChange={(event) => 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"
/>
</div>
{modeOptions.length > 0 && (
<div>
<label className="mb-1 block text-[11px] font-medium uppercase tracking-wide text-[var(--muted-foreground)]">
{t("Retrieval mode")}
</label>
<select
value={serverMode}
onChange={(event) => setServerMode(event.target.value)}
disabled={submitting}
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.map((m) => (
<option key={m} value={m}>
{m}
</option>
))}
</select>
</div>
)}
{probe && <ServerProbeVerdict probe={probe} t={t} />}
</div>
);
}
function ServerProbeVerdict({
probe,
t,
}: {
probe: LightRagServerProbe;
t: TFn;
}) {
if (!probe.ok) {
return (
<div className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-[12px] text-red-700 dark:border-red-900 dark:bg-red-950/30 dark:text-red-300">
<span className="flex items-center gap-1.5 font-medium">
<AlertTriangle className="h-3.5 w-3.5 shrink-0" />
{t("Could not connect")}
</span>
{probe.error && <p className="mt-1 leading-relaxed">{probe.error}</p>}
</div>
);
}
return (
<div className="space-y-1 rounded-lg border border-[var(--border)] bg-[var(--muted)]/40 px-3 py-2.5 text-[12px]">
<div className="flex items-center gap-1.5 font-medium text-emerald-700 dark:text-emerald-300">
<Check className="h-3.5 w-3.5 shrink-0" />
{t("Connected to LightRAG server")}
</div>
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-[11.5px] text-[var(--muted-foreground)]">
{probe.core_version && (
<span>{t("Core {{version}}", { version: probe.core_version })}</span>
)}
<span>
{probe.auth_required ? t("API key accepted") : t("Open access")}
</span>
</div>
</div>
);
}
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 (
<>
<div>
<label className="mb-1.5 block text-[11px] font-medium uppercase tracking-wide text-[var(--muted-foreground)]">
{t("Source")}
</label>
<div className="grid gap-2 sm:grid-cols-2">
{providers.map((p) => {
const selected = !linkIsObsidian && linkSource === p.id;
const disabled = submitting || !p.linkable;
return (
<button
key={p.id}
type="button"
disabled={disabled}
onClick={() => setLinkSource(p.id)}
title={
!p.linkable
? t(
"This engine's index lives in the cloud and can't be linked.",
)
: undefined
}
className={`group flex flex-col gap-1 rounded-2xl border p-3 text-left transition-colors disabled:cursor-not-allowed disabled:opacity-50 ${
selected
? "border-[var(--primary)] bg-[var(--primary)]/5"
: "border-[var(--border)] hover:border-[var(--ring)]"
}`}
>
<div className="flex items-center justify-between gap-2">
<span className="text-[13px] font-medium text-[var(--foreground)]">
{p.name}
</span>
{!p.linkable ? (
<span className="inline-flex items-center gap-1 rounded-full bg-[var(--muted)] px-1.5 py-0.5 text-[10px] font-medium text-[var(--muted-foreground)]">
{t("Cloud index")}
</span>
) : selected ? (
<Check className="h-3.5 w-3.5 text-[var(--primary)]" />
) : null}
</div>
<span className="text-[11.5px] leading-snug text-[var(--muted-foreground)]">
{p.description}
</span>
</button>
);
})}
{/* Obsidian — a live vault, no index. */}
<button
type="button"
disabled={submitting}
onClick={() => setLinkSource(OBSIDIAN_SOURCE)}
className={`group flex flex-col gap-1 rounded-2xl border p-3 text-left transition-colors disabled:opacity-50 ${
linkIsObsidian
? "border-[var(--primary)] bg-[var(--primary)]/5"
: "border-[var(--border)] hover:border-[var(--ring)]"
}`}
>
<div className="flex items-center justify-between gap-2">
<span className="flex items-center gap-1.5 text-[13px] font-medium text-[var(--foreground)]">
<FolderOpen className="h-3.5 w-3.5" />
{t("Obsidian")}
</span>
{linkIsObsidian && (
<Check className="h-3.5 w-3.5 text-[var(--primary)]" />
)}
</div>
<span className="text-[11.5px] leading-snug text-[var(--muted-foreground)]">
{t(
"A live Obsidian vault — browsed and edited in place, no index.",
)}
</span>
</button>
</div>
</div>
<div>
<label className="mb-1 block text-[11px] font-medium uppercase tracking-wide text-[var(--muted-foreground)]">
{linkIsObsidian ? t("Vault path") : t("Folder path")}
</label>
<div className="flex gap-2">
<input
value={folderPath}
onChange={(event) => 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 && (
<button
type="button"
onClick={onProbe}
disabled={submitting || probing || folderPath.trim().length === 0}
className="inline-flex shrink-0 items-center gap-1.5 rounded-lg border border-[var(--border)] px-3 text-[12px] font-medium text-[var(--foreground)] transition-colors hover:border-[var(--ring)] disabled:cursor-not-allowed disabled:opacity-40"
>
{probing ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<FolderSearch className="h-3.5 w-3.5" />
)}
{t("Check folder")}
</button>
)}
</div>
<p className="mt-1 text-[11px] text-[var(--muted-foreground)]">
{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.",
)}
</p>
</div>
{!linkIsObsidian && probe && <ProbeVerdict probe={probe} t={t} />}
</>
);
}
function ProbeVerdict({ probe, t }: { probe: LinkedFolderProbe; t: TFn }) {
if (!probe.ok) {
return (
<div className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-[12px] text-red-700 dark:border-red-900 dark:bg-red-950/30 dark:text-red-300">
<span className="flex items-center gap-1.5 font-medium">
<AlertTriangle className="h-3.5 w-3.5 shrink-0" />
{t("This folder can't be linked")}
</span>
{probe.error && <p className="mt-1 leading-relaxed">{probe.error}</p>}
</div>
);
}
const compatible = probe.embedding.compatible;
return (
<div className="space-y-2 rounded-lg border border-[var(--border)] bg-[var(--muted)]/40 px-3 py-2.5 text-[12px]">
<div className="flex items-center gap-1.5 font-medium text-emerald-700 dark:text-emerald-300">
<Check className="h-3.5 w-3.5 shrink-0" />
{t("Ready index found")}
</div>
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-[11.5px] text-[var(--muted-foreground)]">
{probe.version && <span className="font-mono">{probe.version}</span>}
{probe.doc_count != null && (
<span>{t("{{count}} documents", { count: probe.doc_count })}</span>
)}
{compatible === true && (
<span className="inline-flex items-center gap-1 text-emerald-700 dark:text-emerald-300">
<Check className="h-3 w-3" />
{t("Embedding model matches")}
</span>
)}
</div>
{probe.warnings.map((warning, index) => (
<p
key={index}
className="flex items-start gap-1.5 leading-relaxed text-amber-700 dark:text-amber-300"
>
<AlertTriangle className="mt-0.5 h-3.5 w-3.5 shrink-0" />
<span>{warning}</span>
</p>
))}
</div>
);
}