chore: import upstream snapshot with attribution
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

This commit is contained in:
wehub-resource-sync
2026-07-13 13:00:43 +08:00
commit e4dcfc49aa
1668 changed files with 324490 additions and 0 deletions
+899
View File
@@ -0,0 +1,899 @@
"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>
);
}
File diff suppressed because it is too large Load Diff
+377
View File
@@ -0,0 +1,377 @@
"use client";
import { useCallback, useMemo, useRef, useState, type DragEvent } from "react";
import { useTranslation } from "react-i18next";
import { AlertTriangle, CheckCircle2, FileText, Files, X } from "lucide-react";
import type { KnowledgeUploadPolicy } from "@/lib/knowledge-api";
import {
formatFileSize,
mergeSelectedFiles,
selectionFileId,
validateFiles,
type ValidatedFileSelection,
} from "@/lib/knowledge-helpers";
interface DropState {
active: boolean;
invalid: boolean;
draggedCount: number;
}
const EMPTY_DROP_STATE: DropState = {
active: false,
invalid: false,
draggedCount: 0,
};
interface FileDropZoneProps {
files: File[];
onChange: (files: File[]) => void;
uploadPolicy: KnowledgeUploadPolicy;
disabled?: boolean;
compact?: boolean;
hidePolicyHint?: boolean;
}
export default function FileDropZone({
files,
onChange,
uploadPolicy,
disabled = false,
compact = false,
hidePolicyHint = false,
}: FileDropZoneProps) {
const { t } = useTranslation();
const inputRef = useRef<HTMLInputElement>(null);
const dirInputRef = useRef<HTMLInputElement>(null);
const depthRef = useRef(0);
const [dropState, setDropState] = useState<DropState>(EMPTY_DROP_STATE);
// `webkitdirectory`/`directory` are non-standard attrs (not in React's
// typings) but widely supported — set them imperatively to pick a folder.
const setDirInput = useCallback((el: HTMLInputElement | null) => {
dirInputRef.current = el;
if (el) {
el.setAttribute("webkitdirectory", "");
el.setAttribute("directory", "");
}
}, []);
const selection = useMemo<ValidatedFileSelection>(
() => validateFiles(files, uploadPolicy, t),
[files, uploadPolicy, t],
);
const previewDropped = useCallback(
(incoming: File[]) => {
const validated = validateFiles(incoming, uploadPolicy, t);
return {
count: incoming.length,
invalid: validated.invalidFiles.length > 0,
};
},
[t, uploadPolicy],
);
const reset = useCallback(() => {
depthRef.current = 0;
setDropState(EMPTY_DROP_STATE);
}, []);
const handleEnter = useCallback(
(event: DragEvent<HTMLElement>) => {
if (disabled) return;
if (!Array.from(event.dataTransfer.types).includes("Files")) return;
event.preventDefault();
event.stopPropagation();
depthRef.current += 1;
const incoming = Array.from(event.dataTransfer.items)
.filter((item) => item.kind === "file")
.map((item) => item.getAsFile())
.filter((file): file is File => Boolean(file));
const preview = previewDropped(incoming);
setDropState({
active: true,
invalid: preview.invalid,
draggedCount: preview.count,
});
},
[disabled, previewDropped],
);
const handleOver = useCallback(
(event: DragEvent<HTMLElement>) => {
if (disabled) return;
if (!Array.from(event.dataTransfer.types).includes("Files")) return;
event.preventDefault();
event.stopPropagation();
event.dataTransfer.dropEffect = "copy";
const incoming = Array.from(event.dataTransfer.items)
.filter((item) => item.kind === "file")
.map((item) => item.getAsFile())
.filter((file): file is File => Boolean(file));
const preview = previewDropped(incoming);
setDropState({
active: true,
invalid: preview.invalid,
draggedCount: preview.count,
});
},
[disabled, previewDropped],
);
const handleLeave = useCallback(
(event: DragEvent<HTMLElement>) => {
if (disabled) return;
if (!Array.from(event.dataTransfer.types).includes("Files")) return;
event.preventDefault();
event.stopPropagation();
depthRef.current = Math.max(0, depthRef.current - 1);
if (depthRef.current === 0) reset();
},
[disabled, reset],
);
const handleDrop = useCallback(
(event: DragEvent<HTMLElement>) => {
if (disabled) return;
if (!Array.from(event.dataTransfer.types).includes("Files")) return;
event.preventDefault();
event.stopPropagation();
const dropped = Array.from(event.dataTransfer.files || []);
reset();
if (!dropped.length) return;
onChange(mergeSelectedFiles(files, dropped));
},
[disabled, files, onChange, reset],
);
const removeFile = useCallback(
(id: string) => {
onChange(files.filter((file) => selectionFileId(file) !== id));
},
[files, onChange],
);
const clearAll = useCallback(() => {
onChange([]);
if (inputRef.current) inputRef.current.value = "";
}, [onChange]);
const padding = compact ? "px-4 py-5" : "px-5 py-7";
return (
<div className="space-y-3">
<button
type="button"
onClick={() => inputRef.current?.click()}
onDragEnter={handleEnter}
onDragLeave={handleLeave}
onDragOver={handleOver}
onDrop={handleDrop}
disabled={disabled}
className={`group flex w-full flex-col items-center justify-center gap-2 rounded-lg border border-dashed text-center transition-colors ${padding} ${
dropState.active
? dropState.invalid
? "border-amber-400 bg-amber-50/60 dark:border-amber-700 dark:bg-amber-950/20"
: "border-sky-400 bg-sky-50/60 dark:border-sky-700 dark:bg-sky-950/20"
: "border-[var(--border)] bg-[var(--background)] hover:border-[var(--foreground)]/25 hover:bg-[var(--muted)]/40"
} ${disabled ? "cursor-not-allowed opacity-50" : ""}`}
>
<Files className="h-5 w-5 text-[var(--muted-foreground)] transition-colors group-hover:text-[var(--foreground)]" />
<div className="space-y-1">
<div className="text-[13px] font-medium text-[var(--foreground)]">
{dropState.active
? dropState.invalid
? t("Some dragged files are not supported")
: t("Drop files to add them")
: files.length
? selection.invalidFiles.length > 0
? t("{{count}} invalid files", {
count: selection.invalidFiles.length,
})
: t("{{count}} files ready", {
count: selection.validFiles.length,
})
: t("Choose files...")}
</div>
<p className="text-[11px] text-[var(--muted-foreground)]">
{dropState.active
? dropState.draggedCount > 0
? t("{{count}} files detected", {
count: dropState.draggedCount,
})
: t("Release to attach the files")
: files.length
? formatFileSize(selection.totalBytes)
: t("Click to browse supported documents")}
</p>
</div>
</button>
{!disabled && (
<button
type="button"
onClick={() => dirInputRef.current?.click()}
className="text-[11px] font-medium text-[var(--muted-foreground)] underline-offset-2 transition-colors hover:text-[var(--foreground)] hover:underline"
>
{t("Or select an entire folder")}
</button>
)}
{!hidePolicyHint && (
<p className="text-[11px] text-[var(--muted-foreground)]">
{uploadPolicy.extensions.length} {t("types")} ·{" "}
{t("Maximum file size: {{size}}", {
size: formatFileSize(uploadPolicy.max_file_size_bytes),
})}
</p>
)}
<input
ref={inputRef}
type="file"
multiple
className="hidden"
accept={uploadPolicy.accept}
onChange={(event) => {
const picked = Array.from(event.target.files || []);
event.target.value = "";
onChange(mergeSelectedFiles(files, picked));
}}
/>
{/* Directory picker — files carry webkitRelativePath so the upload keeps
the folder structure. Unsupported members are flagged, not blocked. */}
<input
ref={setDirInput}
type="file"
multiple
className="hidden"
onChange={(event) => {
const picked = Array.from(event.target.files || []);
event.target.value = "";
onChange(mergeSelectedFiles(files, picked));
}}
/>
{selection.items.length > 0 && (
<SelectionSummary
selection={selection}
onRemove={removeFile}
onClear={clearAll}
/>
)}
</div>
);
}
function SelectionSummary({
selection,
onRemove,
onClear,
}: {
selection: ValidatedFileSelection;
onRemove: (id: string) => void;
onClear: () => void;
}) {
const { t } = useTranslation();
const invalidCount = selection.invalidFiles.length;
const readyCount = selection.validFiles.length;
const hasIssues = invalidCount > 0;
return (
<div
className={`rounded-2xl border p-3 ${
hasIssues
? "border-amber-200 bg-amber-50/80 dark:border-amber-900/70 dark:bg-amber-950/20"
: "border-emerald-200 bg-emerald-50/70 dark:border-emerald-900/60 dark:bg-emerald-950/15"
}`}
>
<div className="flex items-start justify-between gap-3">
<div>
<div className="flex items-center gap-2 text-[13px] font-medium text-[var(--foreground)]">
{hasIssues ? (
<AlertTriangle className="h-4 w-4 text-amber-600 dark:text-amber-400" />
) : (
<CheckCircle2 className="h-4 w-4 text-emerald-600 dark:text-emerald-400" />
)}
{hasIssues
? t("{{ready}} ready, {{skip}} will be skipped", {
ready: readyCount,
skip: invalidCount,
})
: t("{{count}} files ready", { count: readyCount })}
</div>
<p className="mt-1 text-[11px] text-[var(--muted-foreground)]">
{hasIssues
? t("Unsupported files are skipped; the rest will be indexed.")
: t("Ready to upload")}{" "}
· {formatFileSize(selection.totalBytes)}
</p>
</div>
<button
type="button"
onClick={onClear}
className="rounded-md border border-[var(--border)] px-2 py-1 text-[11px] text-[var(--muted-foreground)] transition-colors hover:bg-[var(--background)] hover:text-[var(--foreground)]"
>
{t("Clear selection")}
</button>
</div>
<div className="mt-3 space-y-2">
{selection.items.map((item) => (
<div
key={item.id}
className={`flex items-start gap-3 rounded-xl border px-3 py-2.5 ${
item.valid
? "border-white/60 bg-white/70 dark:border-white/10 dark:bg-white/5"
: "border-amber-200/80 bg-amber-100/60 dark:border-amber-900/60 dark:bg-amber-950/20"
}`}
>
<div
className={`mt-0.5 rounded-lg p-2 ${
item.valid
? "bg-emerald-100 text-emerald-700 dark:bg-emerald-950/30 dark:text-emerald-300"
: "bg-amber-100 text-amber-700 dark:bg-amber-950/30 dark:text-amber-300"
}`}
>
<FileText className="h-3.5 w-3.5" />
</div>
<div className="min-w-0 flex-1">
<div className="truncate text-[12px] font-medium text-[var(--foreground)]">
{item.file.name}
</div>
<div className="mt-1 flex flex-wrap items-center gap-2 text-[10px] uppercase tracking-[0.12em] text-[var(--muted-foreground)]">
<span>{item.extension}</span>
<span>{item.sizeLabel}</span>
<span
className={`rounded-full px-2 py-0.5 normal-case tracking-normal ${
item.valid
? "bg-emerald-100 text-emerald-700 dark:bg-emerald-950/30 dark:text-emerald-300"
: "bg-amber-100 text-amber-700 dark:bg-amber-950/30 dark:text-amber-300"
}`}
>
{item.valid ? t("Supported") : t("Needs attention")}
</span>
</div>
{item.error && (
<p className="mt-1.5 text-[11px] leading-relaxed text-amber-700 dark:text-amber-300">
{item.error}
</p>
)}
</div>
<button
type="button"
onClick={() => onRemove(item.id)}
title={t("Remove")}
className="rounded-md p-1.5 text-[var(--muted-foreground)] transition-colors hover:bg-[var(--background)] hover:text-[var(--foreground)]"
>
<X className="h-3.5 w-3.5" />
</button>
</div>
))}
</div>
</div>
);
}
@@ -0,0 +1,54 @@
"use client";
import { useTranslation } from "react-i18next";
import type { IndexVersion } from "@/lib/knowledge-helpers";
interface IndexVersionChipProps {
version: IndexVersion;
activeSignature?: string | null;
}
export default function IndexVersionChip({
version,
activeSignature,
}: IndexVersionChipProps) {
const { t } = useTranslation();
const matchesActive =
!!version.signature && version.signature === activeSignature;
const isActive = matchesActive && version.ready === true;
const isPhantomActive = matchesActive && version.ready !== true;
const dimensionLabel =
typeof version.dimension === "number"
? ` · ${version.dimension}${t("d")}`
: "";
const label = version.legacy
? t("Legacy")
: version.model
? `${version.model}${dimensionLabel}`
: (version.signature ?? t("Unknown"));
const className = isActive
? "border-emerald-300 bg-emerald-50 text-emerald-700 dark:border-emerald-900 dark:bg-emerald-950/30 dark:text-emerald-300"
: isPhantomActive
? "border-amber-300 bg-amber-50 text-amber-700 line-through decoration-amber-400/70 dark:border-amber-900 dark:bg-amber-950/30 dark:text-amber-300"
: "border-[var(--border)] text-[var(--muted-foreground)]";
const title = isPhantomActive
? t(
"Active embedding's index version exists but is empty — last re-index probably failed.",
)
: version.created_at
? `${t("Created")}: ${version.created_at}`
: undefined;
return (
<span
className={`rounded-full border px-2 py-0.5 text-[11px] ${className}`}
title={title}
>
{isActive ? "★ " : ""}
{label}
</span>
);
}
+588
View File
@@ -0,0 +1,588 @@
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import {
Check,
ChevronDown,
ChevronRight,
Folder,
FolderPlus,
Loader2,
MoveRight,
PanelLeftClose,
PanelLeftOpen,
RefreshCw,
Trash2,
X,
} from "lucide-react";
import { invalidateClientCache } from "@/lib/client-cache";
import {
createKbFolder,
deleteKbFile,
listKnowledgeBaseFiles,
moveKbFile,
type KnowledgeBaseFile,
} from "@/lib/knowledge-api";
import { docIconFor, formatBytes } from "@/lib/doc-attachments";
interface KbDocumentListProps {
kbName: string;
/** Refresh trigger: bumping this prop forces a re-fetch (e.g. after upload). */
refreshKey?: number;
selectedFile: string | null;
onSelect: (file: KnowledgeBaseFile | null) => void;
collapsed: boolean;
onToggleCollapsed: () => void;
}
interface TreeNode {
name: string; // segment label
path: string; // full POSIX path relative to raw/
type: "file" | "folder";
file?: KnowledgeBaseFile;
children: TreeNode[];
}
function parentOf(path: string): string {
const idx = path.lastIndexOf("/");
return idx === -1 ? "" : path.slice(0, idx);
}
function buildTree(entries: KnowledgeBaseFile[]): {
root: TreeNode[];
folderPaths: string[];
} {
const folders = new Map<string, TreeNode>();
const root: TreeNode[] = [];
const folderPaths: string[] = [];
const ensureFolder = (path: string): TreeNode => {
const existing = folders.get(path);
if (existing) return existing;
const node: TreeNode = {
name: path.slice(path.lastIndexOf("/") + 1),
path,
type: "folder",
children: [],
};
folders.set(path, node);
folderPaths.push(path);
const parent = parentOf(path);
if (parent) ensureFolder(parent).children.push(node);
else root.push(node);
return node;
};
// Folders first so a file's parent always exists, and empty folders show.
for (const entry of entries) {
if (entry.type === "folder") ensureFolder(entry.name);
}
for (const entry of entries) {
if (entry.type === "folder") continue;
const node: TreeNode = {
name: entry.name.slice(entry.name.lastIndexOf("/") + 1),
path: entry.name,
type: "file",
file: entry,
children: [],
};
const parent = parentOf(entry.name);
if (parent) ensureFolder(parent).children.push(node);
else root.push(node);
}
const sortNodes = (nodes: TreeNode[]) => {
nodes.sort((a, b) => {
if (a.type !== b.type) return a.type === "folder" ? -1 : 1;
return a.name.toLowerCase().localeCompare(b.name.toLowerCase());
});
nodes.forEach((n) => n.children.length && sortNodes(n.children));
};
sortNodes(root);
folderPaths.sort((a, b) => a.toLowerCase().localeCompare(b.toLowerCase()));
return { root, folderPaths };
}
export default function KbDocumentList({
kbName,
refreshKey = 0,
selectedFile,
onSelect,
collapsed,
onToggleCollapsed,
}: KbDocumentListProps) {
const { t } = useTranslation();
const [files, setFiles] = useState<KnowledgeBaseFile[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [expanded, setExpanded] = useState<Set<string>>(new Set());
const [newFolderOpen, setNewFolderOpen] = useState(false);
const [newFolderName, setNewFolderName] = useState("");
const [moveMenuFor, setMoveMenuFor] = useState<string | null>(null);
const [confirmDeleteFor, setConfirmDeleteFor] = useState<string | null>(null);
const [dragPath, setDragPath] = useState<string | null>(null);
const [dropTarget, setDropTarget] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const load = useCallback(
async (force = false) => {
setLoading(true);
setError(null);
try {
if (force) invalidateClientCache(`knowledge:files:${kbName}`);
const next = await listKnowledgeBaseFiles(kbName, { force });
setFiles(next);
// Default-expand every folder so files are visible without digging.
setExpanded(
new Set(next.filter((e) => e.type === "folder").map((e) => e.name)),
);
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setLoading(false);
}
},
[kbName],
);
useEffect(() => {
void load(refreshKey > 0);
}, [load, refreshKey]);
const { root, folderPaths } = useMemo(() => buildTree(files), [files]);
const fileEntries = useMemo(
() => files.filter((e) => e.type !== "folder"),
[files],
);
const toggleFolder = (path: string) =>
setExpanded((prev) => {
const next = new Set(prev);
if (next.has(path)) next.delete(path);
else next.add(path);
return next;
});
const handleCreateFolder = async () => {
const name = newFolderName.trim();
if (!name) return;
setBusy(true);
try {
await createKbFolder(kbName, name);
setNewFolderName("");
setNewFolderOpen(false);
await load(true);
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setBusy(false);
}
};
const handleMove = async (source: string, destFolder: string) => {
setMoveMenuFor(null);
setDropTarget(null);
setDragPath(null);
if (parentOf(source) === destFolder) return; // already there
setBusy(true);
try {
await moveKbFile(kbName, source, destFolder);
const basename = source.slice(source.lastIndexOf("/") + 1);
const newPath = destFolder ? `${destFolder}/${basename}` : basename;
await load(true);
// Keep the preview pointed at the moved file if it was selected.
if (selectedFile === source) {
const moved = files.find((f) => f.name === source);
onSelect({
...(moved ?? { name: newPath }),
name: newPath,
type: "file",
});
}
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setBusy(false);
}
};
const handleDelete = async (path: string) => {
setConfirmDeleteFor(null);
setBusy(true);
try {
await deleteKbFile(kbName, path);
// Drop the preview if the deleted file was the one being shown.
if (selectedFile === path) onSelect(null);
await load(true);
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setBusy(false);
}
};
if (collapsed) {
return (
<aside className="flex h-full w-[44px] shrink-0 flex-col items-center gap-1 border-r border-[var(--border)] bg-[var(--card)]/40 py-2">
<button
type="button"
onClick={onToggleCollapsed}
title={t("Expand")}
aria-label={t("Expand")}
className="flex h-7 w-7 items-center justify-center rounded-md text-[var(--muted-foreground)] transition-colors hover:bg-[var(--muted)] hover:text-[var(--foreground)]"
>
<PanelLeftOpen size={13} strokeWidth={1.7} />
</button>
<div className="my-1 h-px w-6 bg-[var(--border)]/60" />
<div className="flex w-full flex-1 flex-col items-center gap-0.5 overflow-y-auto pb-2">
{fileEntries.map((file) => {
const spec = docIconFor(file.name);
const Icon = spec.Icon;
const active = selectedFile === file.name;
return (
<button
key={file.name}
type="button"
onClick={() => onSelect(file)}
title={file.name}
aria-label={file.name}
className={`relative flex h-8 w-8 shrink-0 items-center justify-center rounded-md transition-colors ${
active
? "bg-[var(--primary)]/12 ring-1 ring-[var(--primary)]/40"
: "hover:bg-[var(--muted)]/60"
}`}
>
{active && (
<span className="absolute -left-1 top-1/2 h-4 w-[2.5px] -translate-y-1/2 rounded-full bg-[var(--primary)]" />
)}
<Icon size={13} strokeWidth={1.6} className={spec.tint} />
</button>
);
})}
</div>
</aside>
);
}
const renderNode = (node: TreeNode, depth: number): React.ReactNode => {
const indent = { paddingLeft: `${depth * 12 + 8}px` };
if (node.type === "folder") {
const open = expanded.has(node.path);
const isDrop = dropTarget === node.path;
return (
<li key={`d:${node.path}`}>
<div
onClick={() => toggleFolder(node.path)}
onDragOver={(e) => {
e.preventDefault();
e.stopPropagation();
setDropTarget(node.path);
}}
onDragLeave={() =>
setDropTarget((cur) => (cur === node.path ? null : cur))
}
onDrop={(e) => {
e.preventDefault();
e.stopPropagation();
const src = e.dataTransfer.getData("text/plain");
if (src) void handleMove(src, node.path);
}}
style={indent}
className={`flex cursor-pointer items-center gap-1 rounded-md py-1.5 pr-2 text-left transition-colors ${
isDrop
? "bg-[var(--primary)]/15 ring-1 ring-[var(--primary)]/40"
: "hover:bg-[var(--muted)]/50"
}`}
>
{open ? (
<ChevronDown className="h-3 w-3 shrink-0 text-[var(--muted-foreground)]" />
) : (
<ChevronRight className="h-3 w-3 shrink-0 text-[var(--muted-foreground)]" />
)}
<Folder className="h-3.5 w-3.5 shrink-0 text-[var(--muted-foreground)]" />
<span className="truncate text-[12px] font-medium text-[var(--foreground)]">
{node.name}
</span>
</div>
{open && node.children.length > 0 && (
<ul className="space-y-px">
{node.children.map((child) => renderNode(child, depth + 1))}
</ul>
)}
</li>
);
}
const spec = docIconFor(node.name);
const Icon = spec.Icon;
const active = selectedFile === node.path;
const file = node.file!;
return (
<li key={`f:${node.path}`} className="group/row relative">
<div
draggable
onDragStart={(e) => {
e.dataTransfer.setData("text/plain", node.path);
e.dataTransfer.effectAllowed = "move";
setDragPath(node.path);
}}
onDragEnd={() => setDragPath(null)}
style={indent}
className={`flex items-center gap-2 rounded-md py-1.5 pr-1 text-left transition-colors ${
active
? "bg-[var(--primary)]/10 text-[var(--foreground)]"
: "hover:bg-[var(--muted)]/50"
} ${dragPath === node.path ? "opacity-50" : ""}`}
>
<button
type="button"
onClick={() => onSelect(file)}
title={node.path}
className="flex min-w-0 flex-1 items-center gap-2 text-left"
>
<Icon
size={13}
strokeWidth={1.6}
className={`shrink-0 ${spec.tint}`}
/>
<div className="min-w-0 flex-1">
<div className="truncate text-[12px] font-medium text-[var(--foreground)]">
{node.name}
</div>
<div className="truncate text-[10px] text-[var(--muted-foreground)]">
{file.size ? formatBytes(file.size) : ""}
{file.modified ? ` · ${formatRelative(file.modified)}` : ""}
</div>
</div>
</button>
{confirmDeleteFor === node.path ? (
<div className="flex shrink-0 items-center gap-0.5 pr-0.5">
<span className="px-0.5 text-[10px] font-medium text-red-600 dark:text-red-400">
{t("Delete?")}
</span>
<button
type="button"
onClick={() => void handleDelete(node.path)}
disabled={busy}
title={t("Confirm delete")}
aria-label={t("Confirm delete")}
className="rounded p-1 text-red-600 transition-colors hover:bg-red-50 disabled:opacity-40 dark:text-red-400 dark:hover:bg-red-950/40"
>
<Check className="h-3.5 w-3.5" />
</button>
<button
type="button"
onClick={() => setConfirmDeleteFor(null)}
title={t("Cancel")}
aria-label={t("Cancel")}
className="rounded p-1 text-[var(--muted-foreground)] transition-colors hover:bg-[var(--muted)] hover:text-[var(--foreground)]"
>
<X className="h-3.5 w-3.5" />
</button>
</div>
) : (
<div className="flex shrink-0 items-center opacity-0 transition-opacity group-hover/row:opacity-100">
<button
type="button"
onClick={() => {
setConfirmDeleteFor(null);
setMoveMenuFor((cur) =>
cur === node.path ? null : node.path,
);
}}
title={t("Move to…")}
aria-label={t("Move to…")}
className="rounded p-1 text-[var(--muted-foreground)] transition-colors hover:bg-[var(--muted)] hover:text-[var(--foreground)]"
>
<MoveRight className="h-3.5 w-3.5" />
</button>
<button
type="button"
onClick={() => {
setMoveMenuFor(null);
setConfirmDeleteFor(node.path);
}}
title={t("Delete file")}
aria-label={t("Delete file")}
className="rounded p-1 text-[var(--muted-foreground)] transition-colors hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-950/40 dark:hover:text-red-400"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
)}
</div>
{moveMenuFor === node.path && (
<>
<div
className="fixed inset-0 z-10"
onClick={() => setMoveMenuFor(null)}
/>
<div className="absolute right-1 top-8 z-20 max-h-60 w-44 overflow-y-auto rounded-lg border border-[var(--border)] bg-[var(--card)] py-1 shadow-lg">
<div className="px-2.5 py-1 text-[10px] uppercase tracking-wide text-[var(--muted-foreground)]">
{t("Move to")}
</div>
{parentOf(node.path) !== "" && (
<button
type="button"
onClick={() => void handleMove(node.path, "")}
className="block w-full truncate px-2.5 py-1.5 text-left text-[12px] text-[var(--foreground)] transition-colors hover:bg-[var(--muted)]/60"
>
/ {t("Root")}
</button>
)}
{folderPaths
.filter((p) => p !== parentOf(node.path))
.map((p) => (
<button
key={p}
type="button"
onClick={() => void handleMove(node.path, p)}
className="block w-full truncate px-2.5 py-1.5 text-left text-[12px] text-[var(--foreground)] transition-colors hover:bg-[var(--muted)]/60"
>
{p}
</button>
))}
{folderPaths.length === 0 && parentOf(node.path) === "" && (
<div className="px-2.5 py-1.5 text-[11px] text-[var(--muted-foreground)]">
{t("No folders yet")}
</div>
)}
</div>
</>
)}
</li>
);
};
return (
<aside className="flex h-full w-[220px] shrink-0 flex-col border-r border-[var(--border)] bg-[var(--card)]/40">
<div className="flex items-center justify-between gap-1 px-2.5 pb-1.5 pt-2.5">
<div className="flex min-w-0 items-center gap-1.5">
<span className="text-[12px] font-medium text-[var(--foreground)]">
{t("Files")}
</span>
<span className="rounded-full bg-[var(--muted)] px-1.5 py-0 text-[10px] text-[var(--muted-foreground)]">
{fileEntries.length}
</span>
</div>
<div className="flex shrink-0 items-center gap-0.5">
<button
type="button"
onClick={() => {
setNewFolderOpen((v) => !v);
setNewFolderName("");
}}
title={t("New folder")}
aria-label={t("New folder")}
className="rounded-md p-1 text-[var(--muted-foreground)] transition-colors hover:bg-[var(--muted)] hover:text-[var(--foreground)]"
>
<FolderPlus size={13} strokeWidth={1.7} />
</button>
<button
type="button"
onClick={() => void load(true)}
title={t("Refresh")}
aria-label={t("Refresh")}
disabled={loading || busy}
className="rounded-md p-1 text-[var(--muted-foreground)] transition-colors hover:bg-[var(--muted)] hover:text-[var(--foreground)] disabled:opacity-40"
>
{loading ? (
<Loader2 className="h-3 w-3 animate-spin" />
) : (
<RefreshCw className="h-3 w-3" />
)}
</button>
<button
type="button"
onClick={onToggleCollapsed}
title={t("Collapse")}
aria-label={t("Collapse")}
className="rounded-md p-1 text-[var(--muted-foreground)] transition-colors hover:bg-[var(--muted)] hover:text-[var(--foreground)]"
>
<PanelLeftClose size={12} strokeWidth={1.7} />
</button>
</div>
</div>
{newFolderOpen && (
<div className="flex items-center gap-1 px-2.5 pb-1.5">
<input
value={newFolderName}
onChange={(e) => setNewFolderName(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") void handleCreateFolder();
if (e.key === "Escape") setNewFolderOpen(false);
}}
autoFocus
placeholder={t("Folder name")}
className="min-w-0 flex-1 rounded-md border border-[var(--border)] bg-[var(--background)] px-2 py-1 text-[12px] text-[var(--foreground)] outline-none focus:border-[var(--foreground)]/25"
/>
<button
type="button"
onClick={() => void handleCreateFolder()}
disabled={busy || !newFolderName.trim()}
className="shrink-0 rounded-md bg-[var(--primary)] px-2 py-1 text-[11px] font-medium text-[var(--primary-foreground)] transition-opacity hover:opacity-90 disabled:opacity-40"
>
{t("Add")}
</button>
</div>
)}
<div
className="flex-1 overflow-y-auto px-1.5 pb-2.5"
onDragOver={(e) => {
e.preventDefault();
setDropTarget("");
}}
onDrop={(e) => {
e.preventDefault();
const src = e.dataTransfer.getData("text/plain");
if (src) void handleMove(src, "");
}}
>
{error ? (
<div className="rounded-md border border-red-200 bg-red-50 px-2.5 py-2 text-[11px] text-red-700 dark:border-red-900 dark:bg-red-950/30 dark:text-red-300">
{error}
<button
type="button"
onClick={() => void load(true)}
className="ml-1 underline"
>
{t("Retry")}
</button>
</div>
) : loading && !files.length ? (
<div className="space-y-1">
{[0, 1, 2].map((i) => (
<div
key={i}
className="h-8 rounded-md bg-[var(--muted)]/40 animate-pulse"
/>
))}
</div>
) : fileEntries.length === 0 && folderPaths.length === 0 ? (
<div className="px-2 py-6 text-center text-[11px] text-[var(--muted-foreground)]">
<Folder className="mx-auto mb-1.5 h-3.5 w-3.5 opacity-50" />
{t("No files yet. Add one using the Add Documents tab.")}
</div>
) : (
<ul className="space-y-px">{root.map((n) => renderNode(n, 0))}</ul>
)}
</div>
</aside>
);
}
function formatRelative(unixSeconds: number): string {
const ts = unixSeconds * 1000;
const diff = Date.now() - ts;
if (diff < 60_000) return "just now";
if (diff < 60 * 60_000) return `${Math.floor(diff / 60_000)}m ago`;
if (diff < 24 * 60 * 60_000)
return `${Math.floor(diff / (60 * 60_000))}h ago`;
if (diff < 30 * 24 * 60 * 60_000)
return `${Math.floor(diff / (24 * 60 * 60_000))}d ago`;
return new Date(ts).toLocaleDateString();
}
@@ -0,0 +1,236 @@
"use client";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { Loader2, RefreshCw, Upload } from "lucide-react";
import type { KnowledgeUploadPolicy } from "@/lib/knowledge-api";
import {
kbIsUploadable,
kbNeedsReindex,
resolveKbStatus,
resolveProgressPercent,
validateFiles,
type KnowledgeBase,
} from "@/lib/knowledge-helpers";
import type { TaskState } from "@/hooks/useKnowledgeProgress";
import type { HistoryEntry } from "@/hooks/useKnowledgeHistory";
import ProcessLogs from "@/components/common/ProcessLogs";
import FileDropZone from "./FileDropZone";
import KbUpdateHistory from "./KbUpdateHistory";
interface KbDocumentsSectionProps {
kb: KnowledgeBase;
uploadPolicy: KnowledgeUploadPolicy;
task?: TaskState;
history: HistoryEntry[];
onClearHistory: () => void;
onRetry?: () => Promise<void>;
onUpload: (files: File[]) => Promise<void>;
}
/**
* The "Add documents" tab. Focused on the incremental-upload flow: drop
* zone, upload button, live process logs while a task runs, and a list of
* past update events. The file list and preview live under the separate
* "Files" tab to keep each surface single-purpose.
*/
export default function KbDocumentsSection({
kb,
uploadPolicy,
task,
history,
onClearHistory,
onRetry,
onUpload,
}: KbDocumentsSectionProps) {
const { t } = useTranslation();
const [files, setFiles] = useState<File[]>([]);
const [submitting, setSubmitting] = useState(false);
const [retrySubmitting, setRetrySubmitting] = useState(false);
const uploadable = kbIsUploadable(kb);
const needsReindex = kbNeedsReindex(kb);
const status = resolveKbStatus(kb);
const isError = status === "error";
const isUploadingHere = task?.kind === "upload" && task.executing;
const isIndexingHere =
(task?.kind === "reindex" || task?.kind === "retry") && task.executing;
const isRetryingHere = task?.kind === "retry" && task.executing;
// An error-state KB is not locked: the user can drop the file(s) that failed
// (Files tab) and upload replacements here, instead of being forced to
// delete and rebuild the whole base. Uploads stay open unless a rebuild is
// actively running; legacy/transition states remain genuinely blocked.
const canUpload = uploadable || (isError && !isIndexingHere);
const blockedReason = canUpload
? null
: needsReindex
? t(
"This knowledge base is in legacy index format and needs reindex before upload.",
)
: status !== "ready"
? t(
"This knowledge base is currently {{status}} and cannot accept uploads yet.",
{ status: status.replaceAll("_", " ") },
)
: null;
const selection = validateFiles(files, uploadPolicy, t);
const canRetry = Boolean(onRetry) && isError && !isIndexingHere;
// Unsupported files are skipped (shown in the drop zone), not blocking, so a
// picked folder with mixed content still uploads its supported members.
const canSubmit =
canUpload &&
selection.validFiles.length > 0 &&
!submitting &&
!isUploadingHere;
const handleSubmit = async () => {
if (!canSubmit) return;
setSubmitting(true);
try {
await onUpload(selection.validFiles);
setFiles([]);
} finally {
setSubmitting(false);
}
};
const handleRetry = async () => {
if (!onRetry || !canRetry || retrySubmitting) return;
setRetrySubmitting(true);
try {
await onRetry();
} finally {
setRetrySubmitting(false);
}
};
const percent = resolveProgressPercent(kb.progress);
const showTaskLogs =
task?.kind === "upload" ||
task?.kind === "create" ||
task?.kind === "reindex" ||
task?.kind === "retry";
const taskLogTitle =
task?.kind === "create"
? t("Create Process")
: task?.kind === "retry"
? t("Retry Process")
: task?.kind === "reindex"
? t("Re-index Process")
: t("Upload Process");
return (
<div className="space-y-5">
<div>
<div className="text-[13px] font-medium text-[var(--foreground)]">
{t("Add documents")}
</div>
<p className="mt-0.5 text-[11.5px] text-[var(--muted-foreground)]">
{t(
"Drop files here to add them to this knowledge base. New files are indexed against the active embedding model.",
)}
</p>
</div>
{blockedReason && (
<div className="rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-[12px] text-amber-700 dark:border-amber-900 dark:bg-amber-950/30 dark:text-amber-300">
{blockedReason}
</div>
)}
{isError && !blockedReason && (
<div className="flex flex-wrap items-center justify-between gap-2 rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-[12px] text-amber-700 dark:border-amber-900 dark:bg-amber-950/30 dark:text-amber-300">
<span>
{t(
"The last indexing run failed. Remove the file(s) that failed in the Files tab, upload replacements below, or retry to rebuild from the current documents.",
)}
</span>
{onRetry && (
<button
type="button"
onClick={handleRetry}
disabled={!canRetry || retrySubmitting}
className="inline-flex shrink-0 items-center gap-1.5 rounded-md border border-amber-300 bg-amber-100 px-2 py-1 text-[11.5px] font-medium text-amber-800 transition-colors hover:bg-amber-200 disabled:opacity-50 dark:border-amber-800 dark:bg-amber-950/50 dark:text-amber-200"
>
{retrySubmitting || isRetryingHere ? (
<Loader2 className="h-3 w-3 animate-spin" />
) : (
<RefreshCw className="h-3 w-3" />
)}
{retrySubmitting || isRetryingHere
? t("Retrying…")
: t("Retry indexing")}
</button>
)}
</div>
)}
<FileDropZone
files={files}
onChange={setFiles}
uploadPolicy={uploadPolicy}
disabled={!canUpload || isUploadingHere}
/>
<div className="flex items-center justify-end">
<button
type="button"
onClick={handleSubmit}
disabled={!canSubmit}
className="inline-flex items-center gap-1.5 rounded-lg bg-[var(--primary)] px-3.5 py-1.5 text-[13px] font-medium text-[var(--primary-foreground)] transition-opacity hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-40"
>
{submitting || isUploadingHere ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Upload size={14} />
)}
{t("Upload")}
</button>
</div>
{showTaskLogs &&
task &&
(task.taskId || task.logs.length > 0 || task.executing) && (
<div className="space-y-2">
<div className="flex items-center justify-between text-[11px] text-[var(--muted-foreground)]">
<span>
{task.label}
{task.taskId ? ` · ${task.taskId}` : ""}
</span>
{task.executing && percent > 0 && (
<span className="font-medium text-[var(--foreground)]">
{percent}%
</span>
)}
</div>
<ProcessLogs
logs={task.logs}
executing={task.executing}
title={taskLogTitle}
/>
{task.executing && (
<div className="h-1.5 overflow-hidden rounded-full bg-[var(--border)]/70">
<div
className="h-full rounded-full bg-[var(--primary)] transition-all duration-300"
style={{ width: `${Math.max(percent, 4)}%` }}
/>
</div>
)}
{task.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">
{task.error}
</pre>
</div>
)}
</div>
)}
<KbUpdateHistory entries={history} onClear={onClearHistory} />
</div>
);
}
+329
View File
@@ -0,0 +1,329 @@
"use client";
import dynamic from "next/dynamic";
import { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import {
Check,
Copy,
Download,
FileText,
Loader2,
Maximize2,
Minimize2,
PanelLeftClose,
PanelLeftOpen,
} from "lucide-react";
import { apiUrl } from "@/lib/api";
import { docIconFor, formatBytes } from "@/lib/doc-attachments";
import {
previewKindFor,
resolveSourceUrl,
type FilePreviewSource,
} from "@/components/chat/preview/previewerFor";
// Full-size loading placeholder. Crucially, giving each dynamic() its own
// `loading` keeps chunk-load suspension *inside* the preview pane. Without it,
// the suspension bubbles up to the route-level <Suspense> wrapping the whole
// page, so first-preview / file-type-switch flashed the entire page to a
// spinner.
const PreviewLoading = () => (
<div className="flex h-full w-full items-center justify-center bg-[var(--background)]">
<Loader2 className="h-4 w-4 animate-spin text-[var(--muted-foreground)]" />
</div>
);
// Reuse the chat renderers — they're pure presentation given (url, filename)
// and don't carry chat-specific state. next/dynamic's compiler requires the
// options to be an inline object literal (not a shared const), so each call
// repeats `{ loading, ssr }`.
const PdfPreview = dynamic(
() => import("@/components/chat/preview/previewers/PdfPreview"),
{ loading: PreviewLoading, ssr: false },
);
const ImagePreview = dynamic(
() => import("@/components/chat/preview/previewers/ImagePreview"),
{ loading: PreviewLoading, ssr: false },
);
const SvgPreview = dynamic(
() => import("@/components/chat/preview/previewers/SvgPreview"),
{ loading: PreviewLoading, ssr: false },
);
const MarkdownPreview = dynamic(
() => import("@/components/chat/preview/previewers/MarkdownPreview"),
{ loading: PreviewLoading, ssr: false },
);
const TextPreview = dynamic(
() => import("@/components/chat/preview/previewers/TextPreview"),
{ loading: PreviewLoading, ssr: false },
);
const DocxPreview = dynamic(
() => import("@/components/chat/preview/previewers/DocxPreview"),
{ loading: PreviewLoading, ssr: false },
);
const XlsxPreview = dynamic(
() => import("@/components/chat/preview/previewers/XlsxPreview"),
{ loading: PreviewLoading, ssr: false },
);
const OfficeTextPreview = dynamic(
() => import("@/components/chat/preview/previewers/OfficeTextPreview"),
{ loading: PreviewLoading, ssr: false },
);
const FallbackPreview = dynamic(
() => import("@/components/chat/preview/previewers/FallbackPreview"),
{ loading: PreviewLoading, ssr: false },
);
interface KbFilePreviewProps {
source: FilePreviewSource | null;
/**
* Optional slot rendered to the right of the breadcrumb / title — used
* to surface metadata (e.g. file count, modified date) inline in the
* preview header, since this component is the right pane of the
* master-detail and there's no separate header above it.
*/
metaSuffix?: React.ReactNode;
/** Current collapsed state of the file list. When provided, the preview
* header shows a toggle so the user can claim more width without
* reaching for the file list's own toggle button. */
fileListCollapsed?: boolean;
onToggleFileList?: () => void;
}
/**
* Inline file preview pane that fits the /knowledge master-detail layout.
*
* Unlike the chat's slide-in drawer, this is just an in-flow panel — header
* (filename + actions) on top, body (renderer) below. It owns no animation
* timers and no portal; the parent decides how/when to mount it. That keeps
* the visual language consistent with the rest of the knowledge surface
* (tabs, sidebars, plain panels) instead of pulling in a drawer chrome that
* fights the page's existing master-detail.
*/
export default function KbFilePreview({
source,
metaSuffix,
fileListCollapsed,
onToggleFileList,
}: KbFilePreviewProps) {
const { t } = useTranslation();
const [copied, setCopied] = useState(false);
// Master-detail caps the inline pane height; fullscreen lets a tall PDF use
// the whole window so you're not stuck reading half a page at a time.
const [fullscreen, setFullscreen] = useState(false);
useEffect(() => {
if (!fullscreen) return;
const onKey = (event: KeyboardEvent) => {
if (event.key === "Escape") setFullscreen(false);
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [fullscreen]);
const previewUrl = useMemo(
() => (source ? resolveSourceUrl(source, apiUrl) : null),
[source],
);
const extractedTextUrl = useMemo(() => {
if (!source?.extractedTextUrl) return null;
return source.extractedTextUrl.startsWith("http")
? source.extractedTextUrl
: apiUrl(source.extractedTextUrl);
}, [source]);
const kind = useMemo(
() => (source ? previewKindFor(source) : null),
[source],
);
if (!source) {
return (
<div className="flex h-full flex-col">
{onToggleFileList && (
<div className="flex items-center justify-end border-b border-[var(--border)] bg-[var(--card)]/40 px-3 py-1.5">
<button
type="button"
onClick={onToggleFileList}
title={
fileListCollapsed ? t("Show file list") : t("Hide file list")
}
aria-label={
fileListCollapsed ? t("Show file list") : t("Hide file list")
}
className="flex h-7 w-7 items-center justify-center rounded-md text-[var(--muted-foreground)] transition-colors hover:bg-[var(--muted)] hover:text-[var(--foreground)]"
>
{fileListCollapsed ? (
<PanelLeftOpen size={13} strokeWidth={1.7} />
) : (
<PanelLeftClose size={13} strokeWidth={1.7} />
)}
</button>
</div>
)}
<div className="flex flex-1 flex-col items-center justify-center gap-3 px-6 py-12 text-center">
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-[var(--muted)] text-[var(--muted-foreground)]">
<FileText className="h-5 w-5" />
</div>
<div>
<div className="text-[13px] font-medium text-[var(--foreground)]">
{t("Select a file to preview")}
</div>
<p className="mt-1 max-w-xs text-[11.5px] leading-relaxed text-[var(--muted-foreground)]">
{t(
"Pick any document from the list on the left to view it here without leaving the knowledge base.",
)}
</p>
</div>
</div>
</div>
);
}
const spec = docIconFor(source.filename);
const HeaderIcon = spec.Icon;
const sizeLabel = source.size ? formatBytes(source.size) : "";
const handleCopy = async () => {
if (!previewUrl) return;
try {
await navigator.clipboard.writeText(previewUrl);
setCopied(true);
setTimeout(() => setCopied(false), 1500);
} catch {
// clipboard rejected; ignore
}
};
return (
<div
className={
fullscreen
? "fixed inset-0 z-50 flex min-h-0 flex-col bg-[var(--background)]"
: "mx-auto flex h-full w-full min-h-0 max-w-5xl flex-col"
}
>
{/* Header */}
<div className="flex items-center gap-2 border-b border-[var(--border)] bg-[var(--card)]/80 px-3 py-2">
{onToggleFileList && !fullscreen && (
<button
type="button"
onClick={onToggleFileList}
title={
fileListCollapsed ? t("Show file list") : t("Hide file list")
}
aria-label={
fileListCollapsed ? t("Show file list") : t("Hide file list")
}
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-md text-[var(--muted-foreground)] transition-colors hover:bg-[var(--muted)] hover:text-[var(--foreground)]"
>
{fileListCollapsed ? (
<PanelLeftOpen size={13} strokeWidth={1.7} />
) : (
<PanelLeftClose size={13} strokeWidth={1.7} />
)}
</button>
)}
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-md bg-[var(--muted)]/60">
<HeaderIcon size={15} strokeWidth={1.6} className={spec.tint} />
</div>
<div className="min-w-0 flex-1">
<div className="truncate text-[12.5px] font-medium text-[var(--foreground)]">
{source.filename}
</div>
<div className="truncate text-[10.5px] uppercase tracking-wide text-[var(--muted-foreground)]">
{sizeLabel ? `${spec.label} · ${sizeLabel}` : spec.label}
</div>
</div>
{metaSuffix && (
<div className="shrink-0 text-[11px] text-[var(--muted-foreground)]">
{metaSuffix}
</div>
)}
<button
type="button"
onClick={() => setFullscreen((value) => !value)}
title={fullscreen ? t("Exit fullscreen") : t("Fullscreen")}
aria-label={fullscreen ? t("Exit fullscreen") : t("Fullscreen")}
className="flex h-7 w-7 items-center justify-center rounded-md text-[var(--muted-foreground)] transition-colors hover:bg-[var(--muted)] hover:text-[var(--foreground)]"
>
{fullscreen ? (
<Minimize2 size={13} strokeWidth={1.7} />
) : (
<Maximize2 size={13} strokeWidth={1.7} />
)}
</button>
{previewUrl && (
<>
<a
href={previewUrl}
download={source.filename}
title={t("Download")}
aria-label={t("Download")}
className="flex h-7 w-7 items-center justify-center rounded-md text-[var(--muted-foreground)] transition-colors hover:bg-[var(--muted)] hover:text-[var(--foreground)]"
>
<Download size={13} strokeWidth={1.7} />
</a>
<button
type="button"
onClick={() => void handleCopy()}
title={t("Copy link")}
aria-label={t("Copy link")}
className="flex h-7 w-7 items-center justify-center rounded-md text-[var(--muted-foreground)] transition-colors hover:bg-[var(--muted)] hover:text-[var(--foreground)]"
>
{copied ? (
<Check
size={13}
strokeWidth={1.7}
className="text-emerald-500"
/>
) : (
<Copy size={13} strokeWidth={1.7} />
)}
</button>
</>
)}
</div>
{/* Body */}
<div className="relative min-h-0 flex-1 overflow-hidden">
{!previewUrl ? (
<FallbackPreview
filename={source.filename}
url={null}
reason="legacy"
/>
) : kind === "office-text" ? (
<OfficeTextPreview
filename={source.filename}
extractedText={source.extractedText}
extractedTextUrl={extractedTextUrl}
url={previewUrl}
/>
) : kind === "pdf" ? (
<PdfPreview url={previewUrl} filename={source.filename} />
) : kind === "docx" ? (
<DocxPreview url={previewUrl} />
) : kind === "xlsx" ? (
<XlsxPreview url={previewUrl} />
) : kind === "image" ? (
<ImagePreview url={previewUrl} filename={source.filename} />
) : kind === "svg" ? (
<SvgPreview url={previewUrl} filename={source.filename} />
) : kind === "markdown" ? (
<div className="h-full overflow-y-auto">
<MarkdownPreview url={previewUrl} />
</div>
) : kind === "code" || kind === "text" ? (
<div className="h-full overflow-y-auto">
<TextPreview url={previewUrl} filename={source.filename} />
</div>
) : (
<FallbackPreview filename={source.filename} url={previewUrl} />
)}
</div>
</div>
);
}
+78
View File
@@ -0,0 +1,78 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import {
knowledgeBaseFilePath,
knowledgeBaseFilePreviewTextPath,
type KnowledgeBaseFile,
} from "@/lib/knowledge-api";
import type { KnowledgeBase } from "@/lib/knowledge-helpers";
import type { TaskState } from "@/hooks/useKnowledgeProgress";
import type { FilePreviewSource } from "@/components/chat/preview/previewerFor";
import { useCollapsiblePanel } from "@/hooks/useCollapsiblePanel";
import KbDocumentList from "./KbDocumentList";
import KbFilePreview from "./KbFilePreview";
interface KbFilesTabProps {
kb: KnowledgeBase;
task?: TaskState;
}
/**
* Master-detail view for the "Files" tab: list of raw documents on the
* left, inline preview pane on the right. Both the parent KB list (in
* `/knowledge`) and this file list can be collapsed to icon-only strips
* to reclaim horizontal space for the actual preview content.
*/
export default function KbFilesTab({ kb, task }: KbFilesTabProps) {
const [selectedFile, setSelectedFile] = useState<KnowledgeBaseFile | null>(
null,
);
const fileListPanel = useCollapsiblePanel("knowledge-file-list");
// Bump refreshKey when the active create/upload task settles so newly
// indexed files appear automatically.
const taskExecuting = task?.executing === true;
const [refreshKey, setRefreshKey] = useState(0);
useEffect(() => {
if (!taskExecuting) {
// eslint-disable-next-line react-hooks/set-state-in-effect
setRefreshKey((n) => n + 1);
}
}, [taskExecuting]);
const previewSource = useMemo<FilePreviewSource | null>(() => {
if (!selectedFile) return null;
return {
filename: selectedFile.name,
mimeType: selectedFile.mime_type ?? undefined,
url: knowledgeBaseFilePath(kb.name, selectedFile.name),
extractedTextUrl: knowledgeBaseFilePreviewTextPath(
kb.name,
selectedFile.name,
),
size: selectedFile.size,
id: `${kb.name}/${selectedFile.name}`,
};
}, [kb.name, selectedFile]);
return (
<div className="flex h-full min-h-0">
<KbDocumentList
kbName={kb.name}
refreshKey={refreshKey}
selectedFile={selectedFile?.name ?? null}
onSelect={setSelectedFile}
collapsed={fileListPanel.collapsed}
onToggleCollapsed={fileListPanel.toggle}
/>
<div className="flex min-h-0 min-w-0 flex-1 flex-col">
<KbFilePreview
source={previewSource}
fileListCollapsed={fileListPanel.collapsed}
onToggleFileList={fileListPanel.toggle}
/>
</div>
</div>
);
}
@@ -0,0 +1,314 @@
"use client";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import {
AlertTriangle,
CheckCircle2,
Clock,
Layers,
Loader2,
RefreshCw,
Star,
} from "lucide-react";
import {
formatKnowledgeTimestamp,
kbCanReindex,
kbNeedsReindex,
resolveKbStatus,
resolveProgressPercent,
type IndexVersion,
type KnowledgeBase,
} from "@/lib/knowledge-helpers";
import type { TaskState } from "@/hooks/useKnowledgeProgress";
import ProcessLogs from "@/components/common/ProcessLogs";
interface KbIndexVersionsSectionProps {
kb: KnowledgeBase;
task?: TaskState;
onReindex: () => Promise<void>;
}
export default function KbIndexVersionsSection({
kb,
task,
onReindex,
}: KbIndexVersionsSectionProps) {
const { t } = useTranslation();
const [submitting, setSubmitting] = useState(false);
const versions = kb.statistics?.index_versions ?? [];
const activeSig = kb.statistics?.active_signature ?? null;
const needsReindex = kbNeedsReindex(kb);
const isError = resolveKbStatus(kb) === "error";
const mismatch = Boolean(kb.metadata?.embedding_mismatch);
const isReindexingHere =
(task?.kind === "reindex" || task?.kind === "retry") && task.executing;
const percent = resolveProgressPercent(kb.progress);
const lastIndexed = formatKnowledgeTimestamp(kb.metadata?.last_indexed_at);
const lastIndexedCount = kb.metadata?.last_indexed_count;
const handleReindex = async () => {
setSubmitting(true);
try {
await onReindex();
} finally {
setSubmitting(false);
}
};
const showReindexCta = kbCanReindex(kb);
return (
<div className="space-y-4">
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2">
<Layers className="h-3.5 w-3.5 text-[var(--muted-foreground)]" />
<div>
<div className="text-[12.5px] font-medium text-[var(--foreground)]">
{t("Index versions")}
<span className="ml-2 rounded-full bg-[var(--muted)] px-1.5 py-0.5 text-[10px] font-normal text-[var(--muted-foreground)]">
{versions.length}
</span>
</div>
<p className="text-[11px] text-[var(--muted-foreground)]">
{t(
"Each embedding configuration gets its own stored vector index.",
)}
</p>
</div>
</div>
{showReindexCta && (
<button
type="button"
onClick={handleReindex}
disabled={submitting || isReindexingHere}
title={
isError
? t(
"Retry indexing from the documents already stored in this knowledge base.",
)
: t(
"Click Re-index to rebuild this knowledge base with the active embedding model. Existing index versions are preserved.",
)
}
className={`inline-flex shrink-0 items-center gap-1.5 rounded-md border px-2.5 py-1 text-[12px] font-medium transition-colors disabled:opacity-50 ${
isError
? "border-red-200 bg-red-50 text-red-700 hover:bg-red-100 dark:border-red-900 dark:bg-red-950/30 dark:text-red-300"
: "border-amber-300 bg-amber-50 text-amber-700 hover:bg-amber-100 dark:border-amber-900 dark:bg-amber-950/30 dark:text-amber-300"
}`}
>
{submitting || isReindexingHere ? (
<Loader2 className="h-3 w-3 animate-spin" />
) : (
<RefreshCw className="h-3 w-3" />
)}
{isReindexingHere
? isError
? t("Retrying…")
: t("Re-indexing…")
: isError
? t("Retry indexing")
: t("Re-index")}
</button>
)}
</div>
{(isError || needsReindex || mismatch) && (
<div
className={`flex items-start gap-2 rounded-lg border px-3 py-2 text-[12px] ${
isError
? "border-red-200 bg-red-50/80 text-red-700 dark:border-red-900/60 dark:bg-red-950/20 dark:text-red-300"
: "border-amber-200 bg-amber-50/80 text-amber-700 dark:border-amber-900/60 dark:bg-amber-950/20 dark:text-amber-300"
}`}
>
<AlertTriangle className="mt-0.5 h-3.5 w-3.5 shrink-0" />
<span>
{isError
? t(
"Previous indexing failed. Retry will rebuild the index from the existing source documents.",
)
: t(
"The active embedding configuration doesn't match any ready index version. Re-index to rebuild against the current embedding model.",
)}
</span>
</div>
)}
<div className="flex flex-wrap items-center gap-x-2 gap-y-1 rounded-lg border border-[var(--border)] bg-[var(--muted)]/30 px-3 py-2 text-[11.5px] text-[var(--muted-foreground)]">
<Clock className="h-3.5 w-3.5 shrink-0" />
<span>
{t("Last indexed")}:{" "}
<span className="font-medium text-[var(--foreground)]">
{lastIndexed || t("Not recorded yet")}
</span>
</span>
{typeof lastIndexedCount === "number" && (
<span>
·{" "}
{t(
lastIndexedCount === 1
? "{{count}} indexed doc"
: "{{count}} indexed docs",
{ count: lastIndexedCount },
)}
</span>
)}
</div>
{versions.length > 0 ? (
<ul className="divide-y divide-[var(--border)] rounded-lg border border-[var(--border)] bg-[var(--background)]">
{versions.map((version) => (
<IndexVersionRow
key={
version.signature ??
`${version.model}-${version.dimension}-${version.created_at}`
}
version={version}
activeSignature={activeSig}
/>
))}
</ul>
) : (
<div className="rounded-lg border border-dashed border-[var(--border)] px-4 py-6 text-center text-[12px] text-[var(--muted-foreground)]">
{t("No index versions yet.")}
</div>
)}
{(task?.kind === "reindex" || task?.kind === "retry") &&
(task.taskId || task.logs.length > 0 || task.executing) && (
<div className="space-y-2">
<div className="flex items-center justify-between text-[11px] text-[var(--muted-foreground)]">
<span>
{task.label}
{task.taskId ? ` · ${task.taskId}` : ""}
</span>
{task.executing && percent > 0 && (
<span className="font-medium text-[var(--foreground)]">
{percent}%
</span>
)}
</div>
<ProcessLogs
logs={task.logs}
executing={task.executing}
title={t("Re-index Process")}
/>
{task.executing && (
<div className="h-1.5 overflow-hidden rounded-full bg-[var(--border)]/70">
<div
className="h-full rounded-full bg-[var(--primary)] transition-all duration-300"
style={{ width: `${Math.max(percent, 4)}%` }}
/>
</div>
)}
{task.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">
{task.error}
</pre>
</div>
)}
</div>
)}
</div>
);
}
function IndexVersionRow({
version,
activeSignature,
}: {
version: IndexVersion;
activeSignature: string | null;
}) {
const { t } = useTranslation();
const matchesActive =
!!version.signature && version.signature === activeSignature;
const isActive = matchesActive && version.ready === true;
const isPhantom = matchesActive && version.ready !== true;
const isLegacy = !!version.legacy;
const title = isLegacy
? t("Legacy index")
: version.model
? version.model
: (version.signature ?? t("Unknown"));
const created = formatKnowledgeTimestamp(version.created_at);
return (
<li className="flex items-center gap-3 px-3 py-2.5">
<div
className={`flex h-7 w-7 shrink-0 items-center justify-center rounded-md ${
isActive
? "bg-emerald-100 text-emerald-600 dark:bg-emerald-950/30 dark:text-emerald-300"
: isPhantom
? "bg-amber-100 text-amber-600 dark:bg-amber-950/30 dark:text-amber-300"
: "bg-[var(--muted)] text-[var(--muted-foreground)]"
}`}
title={
isActive
? t("Active version")
: isPhantom
? t("Stale (matches active config but storage is empty)")
: isLegacy
? t("Legacy index format")
: t("Inactive version")
}
>
{isActive ? (
<Star className="h-3.5 w-3.5" fill="currentColor" />
) : isPhantom ? (
<AlertTriangle className="h-3.5 w-3.5" />
) : isLegacy ? (
<Clock className="h-3.5 w-3.5" />
) : (
<CheckCircle2 className="h-3.5 w-3.5" />
)}
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span
className={`truncate text-[12.5px] font-medium ${
isPhantom
? "text-amber-700 line-through decoration-amber-400/70 dark:text-amber-300"
: "text-[var(--foreground)]"
}`}
>
{title}
</span>
{isActive && (
<span className="rounded-full bg-emerald-100 px-1.5 py-0.5 text-[10px] font-medium text-emerald-700 dark:bg-emerald-950/30 dark:text-emerald-300">
{t("Active")}
</span>
)}
{isPhantom && (
<span className="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("Stale")}
</span>
)}
{isLegacy && !isActive && (
<span className="rounded-full bg-[var(--muted)] px-1.5 py-0.5 text-[10px] text-[var(--muted-foreground)]">
{t("Legacy")}
</span>
)}
</div>
<div className="mt-0.5 flex flex-wrap items-center gap-x-2 gap-y-0.5 text-[10.5px] text-[var(--muted-foreground)]">
{typeof version.dimension === "number" && (
<span>
{version.dimension}
{t("d")}
</span>
)}
{version.binding && <span>{version.binding}</span>}
{created && <span>{created}</span>}
{version.signature && (
<span className="font-mono">{version.signature.slice(0, 10)}</span>
)}
</div>
</div>
</li>
);
}
@@ -0,0 +1,130 @@
"use client";
import { useTranslation } from "react-i18next";
import { Star, Trash2 } from "lucide-react";
import {
formatKnowledgeTimestamp,
type KnowledgeBase,
} from "@/lib/knowledge-helpers";
interface KbSettingsSectionProps {
kb: KnowledgeBase;
onSetDefault: () => Promise<void>;
onDelete: () => Promise<void>;
}
export default function KbSettingsSection({
kb,
onSetDefault,
onDelete,
}: KbSettingsSectionProps) {
const { t } = useTranslation();
const meta = kb.metadata || {};
const provider = kb.statistics?.rag_provider || "llamaindex";
const embeddingLabel = meta.embedding_model
? typeof meta.embedding_dim === "number"
? `${meta.embedding_model} · ${meta.embedding_dim}${t("d")}`
: meta.embedding_model
: t("Default embedding");
const created = formatKnowledgeTimestamp(meta.created_at);
const updated = formatKnowledgeTimestamp(meta.last_updated);
const lastIndexed = formatKnowledgeTimestamp(meta.last_indexed_at);
return (
<div className="space-y-6">
<section className="space-y-3">
<div>
<div className="text-[13px] font-medium text-[var(--foreground)]">
{t("Overview")}
</div>
<p className="mt-0.5 text-[11.5px] text-[var(--muted-foreground)]">
{t("Read-only metadata. Use the actions below to manage this KB.")}
</p>
</div>
<dl className="grid gap-3 rounded-lg border border-[var(--border)] bg-[var(--background)] p-3 sm:grid-cols-2">
<Field label={t("RAG provider")}>{provider}</Field>
<Field label={t("Embedding")}>{embeddingLabel}</Field>
<Field label={t("Created")}>{created || "—"}</Field>
<Field label={t("Updated")}>{updated || "—"}</Field>
<Field label={t("Last indexed")}>{lastIndexed || "—"}</Field>
{kb.path && (
<Field label={t("On-disk path")} className="sm:col-span-2">
<span className="font-mono text-[10.5px] text-[var(--muted-foreground)]">
{kb.path}
</span>
</Field>
)}
</dl>
</section>
<section className="space-y-3 rounded-lg border border-[var(--border)] bg-[var(--background)] p-3">
<div>
<div className="text-[12.5px] font-medium text-[var(--foreground)]">
{t("Default knowledge base")}
</div>
<p className="mt-0.5 text-[11.5px] text-[var(--muted-foreground)]">
{t("The default KB is selected automatically in chat & partners.")}
</p>
</div>
{kb.is_default ? (
<span className="inline-flex items-center gap-1.5 rounded-md bg-amber-100 px-2.5 py-1 text-[12px] font-medium text-amber-700 dark:bg-amber-950/30 dark:text-amber-300">
<Star className="h-3 w-3" fill="currentColor" />
{t("Currently default")}
</span>
) : (
<button
type="button"
onClick={() => void onSetDefault()}
className="inline-flex items-center gap-1.5 rounded-md border border-[var(--border)] bg-[var(--background)] px-2.5 py-1 text-[12px] font-medium text-[var(--foreground)] transition-colors hover:bg-[var(--muted)]"
>
<Star className="h-3 w-3" />
{t("Set as default")}
</button>
)}
</section>
<section className="space-y-3 rounded-lg border border-red-200 bg-red-50/40 p-3 dark:border-red-900/60 dark:bg-red-950/15">
<div>
<div className="text-[12.5px] font-medium text-red-700 dark:text-red-300">
{t("Danger zone")}
</div>
<p className="mt-0.5 text-[11.5px] text-red-700/80 dark:text-red-300/80">
{t(
"Deleting a knowledge base permanently removes its raw documents and index versions.",
)}
</p>
</div>
<button
type="button"
onClick={() => void onDelete()}
className="inline-flex items-center gap-1.5 rounded-md border border-red-300 bg-red-50 px-2.5 py-1 text-[12px] font-medium text-red-700 transition-colors hover:bg-red-100 dark:border-red-900 dark:bg-red-950/30 dark:text-red-300 dark:hover:bg-red-950/50"
>
<Trash2 className="h-3 w-3" />
{t("Delete knowledge base")}
</button>
</section>
</div>
);
}
function Field({
label,
children,
className = "",
}: {
label: string;
children: React.ReactNode;
className?: string;
}) {
return (
<div className={className}>
<dt className="text-[10.5px] uppercase tracking-[0.14em] text-[var(--muted-foreground)]">
{label}
</dt>
<dd className="mt-1 text-[12.5px] text-[var(--foreground)]">
{children}
</dd>
</div>
);
}
@@ -0,0 +1,56 @@
"use client";
import { useTranslation } from "react-i18next";
import { AlertTriangle, CheckCircle2, Clock3 } from "lucide-react";
import {
kbHasLiveProgress,
kbNeedsReindex,
resolveKbStatus,
type KnowledgeBase,
} from "@/lib/knowledge-helpers";
interface KbStatusBadgeProps {
kb: KnowledgeBase;
isReindexingLocally?: boolean;
}
export default function KbStatusBadge({
kb,
isReindexingLocally = false,
}: KbStatusBadgeProps) {
const { t } = useTranslation();
const status = resolveKbStatus(kb);
const needsReindex = kbNeedsReindex(kb);
const isLive = kbHasLiveProgress(kb) || isReindexingLocally;
const isError = status === "error";
const isReady = status === "ready" && !needsReindex;
const tone = needsReindex
? "bg-amber-100 text-amber-700 dark:bg-amber-950/30 dark:text-amber-300"
: isError
? "bg-red-100 text-red-700 dark:bg-red-950/30 dark:text-red-300"
: isLive
? "bg-sky-100 text-sky-700 dark:bg-sky-950/30 dark:text-sky-300"
: "bg-emerald-100 text-emerald-700 dark:bg-emerald-950/30 dark:text-emerald-300";
const Icon = isLive ? Clock3 : isReady ? CheckCircle2 : AlertTriangle;
const label = needsReindex
? t("Needs reindex")
: isError
? t("Error")
: isLive
? t("Processing live")
: isReady
? t("Ready")
: status.replaceAll("_", " ");
return (
<span
className={`inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[10px] font-medium ${tone}`}
>
<Icon className="h-3 w-3" />
{label}
</span>
);
}
@@ -0,0 +1,147 @@
"use client";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import {
AlertCircle,
CheckCircle2,
ChevronDown,
ChevronRight,
History,
RefreshCw,
Trash2,
Upload,
Wand2,
} from "lucide-react";
import type { HistoryEntry } from "@/hooks/useKnowledgeHistory";
interface KbUpdateHistoryProps {
entries: HistoryEntry[];
onClear: () => void;
}
export default function KbUpdateHistory({
entries,
onClear,
}: KbUpdateHistoryProps) {
const { t } = useTranslation();
const [expandedId, setExpandedId] = useState<string | null>(null);
if (entries.length === 0) return null;
return (
<div className="space-y-2">
<div className="flex items-center justify-between">
<div className="flex items-center gap-1.5 text-[11.5px] font-medium uppercase tracking-[0.12em] text-[var(--muted-foreground)]">
<History className="h-3 w-3" />
{t("Update history")}
<span className="rounded-full bg-[var(--muted)] px-1.5 py-0 text-[10px] tracking-normal">
{entries.length}
</span>
</div>
<button
type="button"
onClick={onClear}
title={t("Clear history")}
className="flex h-6 w-6 items-center justify-center rounded text-[var(--muted-foreground)] transition-colors hover:bg-[var(--muted)] hover:text-[var(--foreground)]"
aria-label={t("Clear history")}
>
<Trash2 className="h-3 w-3" />
</button>
</div>
<ul className="divide-y divide-[var(--border)] rounded-lg border border-[var(--border)]">
{entries.map((entry) => {
const expanded = expandedId === entry.id;
const isError = entry.status === "error";
const Icon = iconForKind(entry.kind);
const durationMs = Math.max(0, entry.completedAt - entry.startedAt);
return (
<li key={entry.id} className="text-[12.5px]">
<button
type="button"
onClick={() => setExpandedId(expanded ? null : entry.id)}
className="flex w-full items-start gap-2.5 px-3 py-2.5 text-left transition-colors hover:bg-[var(--muted)]/40"
>
<span className="mt-0.5 shrink-0 text-[var(--muted-foreground)]">
{expanded ? (
<ChevronDown size={13} />
) : (
<ChevronRight size={13} />
)}
</span>
<span
className={`mt-0.5 shrink-0 rounded-md p-1 ${
isError
? "bg-red-100 text-red-700 dark:bg-red-950/30 dark:text-red-300"
: "bg-emerald-100 text-emerald-700 dark:bg-emerald-950/30 dark:text-emerald-300"
}`}
>
{isError ? (
<AlertCircle className="h-3 w-3" />
) : (
<CheckCircle2 className="h-3 w-3" />
)}
</span>
<span className="min-w-0 flex-1">
<span className="flex items-center gap-1.5 text-[12.5px] font-medium text-[var(--foreground)]">
<Icon className="h-3 w-3 text-[var(--muted-foreground)]" />
{entry.label}
</span>
<span className="mt-0.5 block text-[11px] text-[var(--muted-foreground)]">
{new Date(entry.completedAt).toLocaleString()}
{durationMs > 0 && ` · ${formatDuration(durationMs)}`}
</span>
</span>
</button>
{expanded && (
<div className="border-t border-[var(--border)] bg-[var(--muted)]/30 px-3 py-2">
{entry.error && (
<pre className="mb-2 whitespace-pre-wrap break-words rounded-md border border-red-200 bg-red-50 px-2 py-1.5 font-mono text-[11px] leading-relaxed text-red-700 dark:border-red-900 dark:bg-red-950/30 dark:text-red-300">
{entry.error}
</pre>
)}
{entry.logTail.length > 0 ? (
<pre className="max-h-[240px] overflow-auto whitespace-pre-wrap break-words rounded-md border border-[var(--border)] bg-[var(--card)] px-2 py-1.5 font-mono text-[10.5px] leading-relaxed text-[var(--muted-foreground)]">
{entry.logTail.join("\n")}
</pre>
) : (
!entry.error && (
<p className="text-[11px] text-[var(--muted-foreground)]">
{t("No logs captured.")}
</p>
)
)}
</div>
)}
</li>
);
})}
</ul>
</div>
);
}
function iconForKind(kind: HistoryEntry["kind"]) {
switch (kind) {
case "upload":
return Upload;
case "reindex":
return RefreshCw;
case "create":
default:
return Wand2;
}
}
function formatDuration(ms: number): string {
if (ms < 1000) return `${ms}ms`;
if (ms < 60_000) return `${(ms / 1000).toFixed(1)}s`;
const minutes = Math.floor(ms / 60_000);
const seconds = Math.round((ms % 60_000) / 1000);
return `${minutes}m ${seconds}s`;
}
@@ -0,0 +1,273 @@
"use client";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import {
ArrowLeft,
Database,
FileText,
Layers,
Loader2,
RefreshCw,
Settings as SettingsIcon,
Star,
Upload,
} from "lucide-react";
import type { KnowledgeUploadPolicy } from "@/lib/knowledge-api";
import {
formatKnowledgeTimestamp,
resolveKbStatus,
type KnowledgeBase,
} from "@/lib/knowledge-helpers";
import type { TaskState } from "@/hooks/useKnowledgeProgress";
import type { HistoryEntry } from "@/hooks/useKnowledgeHistory";
import KbStatusBadge from "./KbStatusBadge";
import KbFilesTab from "./KbFilesTab";
import KbDocumentsSection from "./KbDocumentsSection";
import KbIndexVersionsSection from "./KbIndexVersionsSection";
import KbSettingsSection from "./KbSettingsSection";
type DetailSection = "files" | "add" | "versions" | "settings";
interface KnowledgeBaseDetailProps {
kb: KnowledgeBase | null;
uploadPolicy: KnowledgeUploadPolicy;
task?: TaskState;
history: HistoryEntry[];
onCreate: () => void;
onUpload: (kbName: string, files: File[]) => Promise<void>;
onReindex: (kbName: string) => Promise<void>;
onRetry: (kbName: string) => Promise<void>;
onSetDefault: (kbName: string) => Promise<void>;
onDelete: (kbName: string) => Promise<void>;
onClearHistory: (kbName: string) => void;
onBack?: () => void;
}
const SECTIONS: {
key: DetailSection;
label: string;
Icon: typeof FileText;
}[] = [
{ key: "files", label: "Files", Icon: FileText },
{ key: "add", label: "Add documents", Icon: Upload },
{ key: "versions", label: "Index versions", Icon: Layers },
{ key: "settings", label: "Settings", Icon: SettingsIcon },
];
/** Sections that fill the detail body edge-to-edge (no max-w wrapper). */
const FULL_BLEED_SECTIONS = new Set<DetailSection>(["files"]);
export default function KnowledgeBaseDetail({
kb,
uploadPolicy,
task,
history,
onCreate,
onUpload,
onReindex,
onRetry,
onSetDefault,
onDelete,
onClearHistory,
onBack,
}: KnowledgeBaseDetailProps) {
const { t } = useTranslation();
const [section, setSection] = useState<DetailSection>("files");
const [retrySubmitting, setRetrySubmitting] = useState(false);
if (!kb) {
return (
<main className="flex flex-1 items-center justify-center bg-[var(--background)] p-6">
<div className="max-w-sm rounded-2xl border border-dashed border-[var(--border)] bg-[var(--card)]/40 p-8 text-center">
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-2xl bg-[var(--muted)] text-[var(--muted-foreground)]">
<Database className="h-5 w-5" />
</div>
<div className="text-[14px] font-medium text-[var(--foreground)]">
{t("No knowledge base selected")}
</div>
<p className="mx-auto mt-2 text-[12px] leading-relaxed text-[var(--muted-foreground)]">
{t(
"Pick a knowledge base from the list, or create a new one to get started.",
)}
</p>
<button
type="button"
onClick={onCreate}
className="mt-4 inline-flex items-center gap-1.5 rounded-lg bg-[var(--primary)] px-3.5 py-1.5 text-[13px] font-medium text-[var(--primary-foreground)] transition-opacity hover:opacity-90"
>
{t("Create your first knowledge base")}
</button>
</div>
</main>
);
}
const meta = kb.metadata || {};
const provider = kb.statistics?.rag_provider || "llamaindex";
const embeddingLabel = meta.embedding_model
? typeof meta.embedding_dim === "number"
? `${meta.embedding_model} · ${meta.embedding_dim}${t("d")}`
: meta.embedding_model
: t("Default embedding");
const updatedLabel =
formatKnowledgeTimestamp(meta.last_updated) || t("Unknown time");
const lastIndexedLabel = formatKnowledgeTimestamp(meta.last_indexed_at);
const isReindexingLocally =
(task?.kind === "reindex" || task?.kind === "retry") &&
task.executing === true;
const status = resolveKbStatus(kb);
const canRetry = status === "error" && !kb.read_only;
const handleRetry = async () => {
if (!canRetry || retrySubmitting || isReindexingLocally) return;
setRetrySubmitting(true);
try {
await onRetry(kb.name);
} finally {
setRetrySubmitting(false);
}
};
const fullBleed = FULL_BLEED_SECTIONS.has(section);
return (
<main className="flex h-full flex-1 flex-col overflow-hidden bg-[var(--background)]">
{/* Header */}
<div className="border-b border-[var(--border)] bg-[var(--card)] px-6 py-4">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1">
{onBack && (
<button
type="button"
onClick={onBack}
className="mb-1.5 inline-flex items-center gap-1 text-[11.5px] font-medium text-[var(--muted-foreground)] transition-colors hover:text-[var(--foreground)]"
>
<ArrowLeft className="h-3.5 w-3.5" />
{t("Knowledge bases")}
</button>
)}
<div className="flex flex-wrap items-center gap-2">
<h1 className="truncate font-serif text-[18px] font-semibold tracking-tight text-[var(--foreground)]">
{kb.name}
</h1>
{kb.is_default && (
<span className="inline-flex items-center gap-1 rounded-full bg-amber-100 px-2 py-0.5 text-[10px] font-medium text-amber-700 dark:bg-amber-950/30 dark:text-amber-300">
<Star className="h-3 w-3" fill="currentColor" />
{t("Default")}
</span>
)}
{kb.assigned && (
<span className="inline-flex items-center rounded-full bg-emerald-500/10 px-2 py-0.5 text-[10px] font-medium text-emerald-700 dark:text-emerald-300">
{kb.provenance_label || t("Assigned by admin")}
</span>
)}
<KbStatusBadge
kb={kb}
isReindexingLocally={isReindexingLocally}
/>
</div>
<p className="mt-1 text-[12px] text-[var(--muted-foreground)]">
{provider} · {embeddingLabel} · {t("Updated")} {updatedLabel}
{lastIndexedLabel
? ` · ${t("Last indexed")} ${lastIndexedLabel}`
: ""}
</p>
</div>
{canRetry && (
<button
type="button"
onClick={handleRetry}
disabled={retrySubmitting || isReindexingLocally}
title={t(
"Retry indexing from the documents already stored in this knowledge base.",
)}
className="inline-flex shrink-0 items-center gap-1.5 rounded-md border border-red-200 bg-red-50 px-2.5 py-1 text-[12px] font-medium text-red-700 transition-colors hover:bg-red-100 disabled:opacity-50 dark:border-red-900 dark:bg-red-950/30 dark:text-red-300"
>
{retrySubmitting || isReindexingLocally ? (
<Loader2 className="h-3 w-3 animate-spin" />
) : (
<RefreshCw className="h-3 w-3" />
)}
{retrySubmitting || isReindexingLocally
? t("Retrying…")
: t("Retry indexing")}
</button>
)}
</div>
{/* Section nav */}
<nav className="-mb-3 mt-3 flex gap-1 overflow-x-auto">
{SECTIONS.map(({ key, label, Icon }) => {
const active = section === key;
return (
<button
key={key}
type="button"
onClick={() => setSection(key)}
className={`inline-flex shrink-0 items-center gap-1.5 rounded-t-md px-3 py-2 text-[12.5px] font-medium transition-colors ${
active
? "border-b-2 border-[var(--primary)] text-[var(--foreground)]"
: "border-b-2 border-transparent text-[var(--muted-foreground)] hover:text-[var(--foreground)]"
}`}
>
<Icon size={13} />
{t(label)}
</button>
);
})}
</nav>
</div>
{/* Body */}
<div className="min-h-0 flex-1 overflow-hidden">
{section === "files" ? (
<KbFilesTab key={kb.name} kb={kb} task={task} />
) : (
<div className="h-full overflow-y-auto px-6 py-5">
<div className={fullBleed ? "" : "mx-auto max-w-3xl"}>
{section === "add" && (
<KbDocumentsSection
kb={kb}
uploadPolicy={uploadPolicy}
task={task}
history={history}
onClearHistory={() => onClearHistory(kb.name)}
onRetry={handleRetry}
onUpload={(files) =>
kb.read_only ? Promise.resolve() : onUpload(kb.name, files)
}
/>
)}
{section === "versions" && (
<KbIndexVersionsSection
kb={kb}
task={task}
onReindex={() =>
kb.read_only
? Promise.resolve()
: status === "error"
? handleRetry()
: onReindex(kb.name)
}
/>
)}
{section === "settings" && (
<KbSettingsSection
kb={kb}
onSetDefault={() =>
kb.read_only ? Promise.resolve() : onSetDefault(kb.name)
}
onDelete={() =>
kb.read_only ? Promise.resolve() : onDelete(kb.name)
}
/>
)}
</div>
</div>
)}
</div>
</main>
);
}
+331
View File
@@ -0,0 +1,331 @@
"use client";
import { useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import {
Boxes,
Check,
ChevronRight,
Cloud,
Cpu,
Database,
FolderOpen,
Network,
Plus,
Search,
Server,
Star,
Workflow,
type LucideIcon,
} from "lucide-react";
import {
kbDocCount,
kbHasLiveProgress,
kbNeedsReindex,
kbProvider,
resolveKbStatus,
type KnowledgeBase,
} from "@/lib/knowledge-helpers";
import type { RagProviderSummary } from "@/lib/knowledge-api";
interface KnowledgeHomeProps {
kbs: KnowledgeBase[];
providers: RagProviderSummary[];
onOpenKb: (name: string) => void;
onOpenEngine: (id: string) => void;
onCreate: () => void;
/** Open the create flow pre-set to link an Obsidian vault. */
onConnectObsidian: () => void;
}
const ENGINE_ICONS: Record<string, LucideIcon> = {
llamaindex: Boxes,
pageindex: Cloud,
graphrag: Network,
lightrag: Workflow,
"lightrag-server": Server,
};
type EngineStatus = "ready" | "needs_key" | "unavailable";
function engineStatus(p: RagProviderSummary): EngineStatus {
if (p.requires_api_key && p.configured === false) return "needs_key";
if (p.configured === false) return "unavailable";
return "ready";
}
function EngineStatusBadge({ status }: { status: EngineStatus }) {
const { t } = useTranslation();
if (status === "ready") {
return (
<span className="inline-flex items-center gap-1 rounded-full bg-emerald-100 px-1.5 py-0.5 text-[10px] font-medium text-emerald-700 dark:bg-emerald-950/30 dark:text-emerald-300">
<Check className="h-3 w-3" />
{t("Ready")}
</span>
);
}
if (status === "needs_key") {
return (
<span className="inline-flex items-center 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>
);
}
return (
<span className="inline-flex items-center rounded-full bg-[var(--muted)] px-1.5 py-0.5 text-[10px] font-medium text-[var(--muted-foreground)]">
{t("Not installed")}
</span>
);
}
function StatusDot({ kb }: { kb: KnowledgeBase }) {
const status = resolveKbStatus(kb);
const needsReindex = kbNeedsReindex(kb);
const isLive = kbHasLiveProgress(kb);
const tone = needsReindex
? "bg-amber-500"
: status === "error"
? "bg-red-500"
: isLive
? "bg-sky-500 animate-pulse"
: status === "ready"
? "bg-emerald-500"
: "bg-[var(--muted-foreground)]";
return <span className={`inline-block h-2 w-2 rounded-full ${tone}`} />;
}
export default function KnowledgeHome({
kbs,
providers,
onOpenKb,
onOpenEngine,
onCreate,
onConnectObsidian,
}: KnowledgeHomeProps) {
const { t } = useTranslation();
const [query, setQuery] = useState("");
const providerName = (id: string) =>
providers.find((p) => p.id === id)?.name ??
id.charAt(0).toUpperCase() + id.slice(1);
const obsidianCount = useMemo(
() => kbs.filter((kb) => kb.metadata?.type === "obsidian").length,
[kbs],
);
const kbCountByProvider = useMemo(() => {
const counts: Record<string, number> = {};
for (const kb of kbs)
counts[kbProvider(kb)] = (counts[kbProvider(kb)] ?? 0) + 1;
return counts;
}, [kbs]);
const filteredKbs = useMemo(() => {
const q = query.trim().toLowerCase();
if (!q) return kbs;
return kbs.filter((kb) => kb.name.toLowerCase().includes(q));
}, [kbs, query]);
return (
<div className="flex-1 overflow-y-auto bg-[var(--background)]">
<div className="mx-auto max-w-4xl px-6 py-8">
{/* Header */}
<div className="flex items-start justify-between gap-4">
<div>
<h1 className="text-[19px] font-semibold tracking-tight text-[var(--foreground)]">
{t("Knowledge Center")}
</h1>
<p className="mt-1 text-[12.5px] text-[var(--muted-foreground)]">
{t("Manage your knowledge bases and retrieval engines.")}
</p>
</div>
<button
type="button"
onClick={onCreate}
className="inline-flex shrink-0 items-center gap-1.5 rounded-lg bg-[var(--primary)] px-3.5 py-2 text-[12.5px] font-medium text-[var(--primary-foreground)] transition-opacity hover:opacity-90"
>
<Plus size={14} />
{t("New knowledge base")}
</button>
</div>
{/* Retrieval engines */}
<section className="mt-8">
<h2 className="mb-3 flex items-center gap-2 text-[11px] font-medium uppercase tracking-wide text-[var(--muted-foreground)]">
<Cpu className="h-3.5 w-3.5" />
{t("Retrieval engines")}
</h2>
<div className="grid grid-cols-1 items-stretch gap-3 sm:grid-cols-2">
{providers.map((p) => {
const status = engineStatus(p);
const Icon = ENGINE_ICONS[p.id] ?? Boxes;
const count = kbCountByProvider[p.id] ?? 0;
return (
<button
key={p.id}
type="button"
onClick={() => onOpenEngine(p.id)}
className="group flex flex-col gap-2 rounded-2xl border border-[var(--border)] p-3.5 text-left transition-colors hover:border-[var(--ring)]"
>
<div className="flex items-center justify-between gap-2">
<div className="flex min-w-0 items-center gap-2">
<Icon
className="h-4 w-4 shrink-0 text-[var(--muted-foreground)]"
strokeWidth={1.7}
/>
<span className="truncate text-[13.5px] font-medium text-[var(--foreground)]">
{p.name}
</span>
</div>
<EngineStatusBadge status={status} />
</div>
<p className="line-clamp-2 text-[11.5px] leading-snug text-[var(--muted-foreground)]">
{p.description}
</p>
<div className="mt-auto flex items-center gap-2 pt-1 text-[11px] text-[var(--muted-foreground)]">
{p.modes && p.modes.length > 0 && p.default_mode && (
<span className="rounded-full border border-[var(--border)] px-1.5 py-0.5 font-mono">
{p.default_mode}
</span>
)}
{count > 0 && <span>{t("{{count}} KB", { count })}</span>}
<ChevronRight className="ml-auto h-3.5 w-3.5 opacity-0 transition-opacity group-hover:opacity-60" />
</div>
</button>
);
})}
{/* Obsidian — a connected source, not a config-backed engine: a
pointer to a live vault the tutor reads & writes in place. Shown
here for discoverability; clicking opens the unified create flow
pre-set to link a vault. */}
<button
type="button"
onClick={onConnectObsidian}
className="group flex flex-col gap-2 rounded-2xl border border-[var(--border)] p-3.5 text-left transition-colors hover:border-[var(--ring)]"
>
<div className="flex items-center justify-between gap-2">
<div className="flex min-w-0 items-center gap-2">
<FolderOpen
className="h-4 w-4 shrink-0 text-[var(--muted-foreground)]"
strokeWidth={1.7}
/>
<span className="truncate text-[13.5px] font-medium text-[var(--foreground)]">
{t("Obsidian")}
</span>
</div>
{obsidianCount > 0 && (
<span className="inline-flex items-center gap-1 rounded-full bg-emerald-100 px-1.5 py-0.5 text-[10px] font-medium text-emerald-700 dark:bg-emerald-950/30 dark:text-emerald-300">
<Check className="h-3 w-3" />
{t("{{count}} connected", { count: obsidianCount })}
</span>
)}
</div>
<p className="line-clamp-2 text-[11.5px] leading-snug text-[var(--muted-foreground)]">
{t(
"Connect your Obsidian vault. The tutor navigates and writes notes in place — no upload, no index. Local / self-hosted only.",
)}
</p>
<div className="mt-auto flex items-center gap-2 pt-1 text-[11px] text-[var(--muted-foreground)]">
<span className="inline-flex items-center gap-1">
<FolderOpen className="h-3 w-3" />
{t("Connect vault")}
</span>
<ChevronRight className="ml-auto h-3.5 w-3.5 opacity-0 transition-opacity group-hover:opacity-60" />
</div>
</button>
</div>
</section>
{/* Knowledge bases */}
<section className="mt-8 pb-2">
<div className="mb-3 flex items-center justify-between gap-2">
<h2 className="flex items-center gap-2 text-[11px] font-medium uppercase tracking-wide text-[var(--muted-foreground)]">
<Database className="h-3.5 w-3.5" />
{t("Knowledge bases")}
<span className="rounded-full bg-[var(--muted)] px-1.5 py-0.5 text-[10px] text-[var(--muted-foreground)]">
{kbs.length}
</span>
</h2>
{kbs.length > 6 && (
<div className="relative w-48">
<Search
className="pointer-events-none absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-[var(--muted-foreground)]"
aria-hidden
/>
<input
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder={t("Search knowledge bases…")}
className="w-full rounded-lg border border-[var(--border)] bg-[var(--background)] py-1.5 pl-8 pr-3 text-[12px] text-[var(--foreground)] outline-none transition-colors placeholder:text-[var(--muted-foreground)] focus:border-[var(--foreground)]/25"
/>
</div>
)}
</div>
{kbs.length === 0 ? (
<div className="rounded-2xl border border-dashed border-[var(--border)] px-4 py-12 text-center">
<Database className="mx-auto mb-2 h-6 w-6 text-[var(--muted-foreground)]" />
<div className="text-[13px] font-medium text-[var(--foreground)]">
{t("No knowledge bases yet")}
</div>
<p className="mx-auto mt-1 max-w-sm text-[12px] leading-relaxed text-[var(--muted-foreground)]">
{t(
"Create one to upload documents and retrieve grounded context in chat.",
)}
</p>
<button
type="button"
onClick={onCreate}
className="mt-4 inline-flex items-center gap-1.5 rounded-lg bg-[var(--primary)] px-3.5 py-2 text-[12.5px] font-medium text-[var(--primary-foreground)] transition-opacity hover:opacity-90"
>
<Plus size={14} />
{t("New knowledge base")}
</button>
</div>
) : filteredKbs.length === 0 ? (
<div className="rounded-2xl border border-dashed border-[var(--border)] px-4 py-8 text-center text-[12px] text-[var(--muted-foreground)]">
{t("No matches")}
</div>
) : (
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
{filteredKbs.map((kb) => {
const docs = kbDocCount(kb);
return (
<button
key={kb.name}
type="button"
onClick={() => onOpenKb(kb.name)}
className="group flex flex-col gap-2 rounded-2xl border border-[var(--border)] p-4 text-left transition-colors hover:border-[var(--ring)]"
>
<div className="flex items-center gap-2">
<StatusDot kb={kb} />
<span className="truncate text-[13.5px] font-medium text-[var(--foreground)]">
{kb.name}
</span>
{kb.is_default && (
<Star
className="h-3 w-3 shrink-0 text-amber-500"
fill="currentColor"
/>
)}
</div>
<div className="flex items-center gap-2 text-[11px] text-[var(--muted-foreground)]">
<span className="rounded-full border border-[var(--border)] px-1.5 py-0.5">
{providerName(kbProvider(kb))}
</span>
{docs !== null && (
<span>
{docs} {t("docs")}
</span>
)}
</div>
</button>
);
})}
</div>
)}
</section>
</div>
</div>
);
}
+323
View File
@@ -0,0 +1,323 @@
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { useTranslation } from "react-i18next";
import { Loader2 } from "lucide-react";
import { useKnowledgeBases } from "@/hooks/useKnowledgeBases";
import { updateRagProviderMode } from "@/lib/knowledge-api";
import KnowledgeBaseDetail from "./KnowledgeBaseDetail";
import KnowledgeHome from "./KnowledgeHome";
import EngineDetail from "./EngineDetail";
import CreateKbModal from "./CreateKbModal";
import PageIndexSettingsModal from "./PageIndexSettingsModal";
export default function KnowledgePage() {
const { t } = useTranslation();
const router = useRouter();
const searchParams = useSearchParams();
const initialKb = searchParams.get("kb");
const initialEngine = searchParams.get("engine");
const {
kbs: allKbs,
providers,
uploadPolicy,
loading,
error,
setError,
tasksByKb,
historyByKb,
clearHistory,
refresh,
createKb,
uploadFiles,
setDefault,
reindex,
retry,
deleteKb,
connectObsidian,
connectLinkedFolder,
connectLightRagServer,
} = useKnowledgeBases();
// Connected subagents are stored as ``type: subagent`` KBs so the chat
// composer can select them, but they are agents, not knowledge bases — keep
// them out of the Knowledge Center entirely.
const kbs = useMemo(
() => allKbs.filter((kb) => kb.metadata?.type !== "subagent"),
[allKbs],
);
const [explicitSelection, setExplicitSelection] = useState<string | null>(
initialKb,
);
const [selectedEngineId, setSelectedEngineId] = useState<string | null>(
initialEngine,
);
const [createOpen, setCreateOpen] = useState(false);
const [createPreset, setCreatePreset] = useState<{
mode: "new" | "link";
source?: string;
} | null>(null);
const [pipelineOpen, setPipelineOpen] = useState(false);
const openCreate = useCallback(() => {
setCreatePreset(null);
setCreateOpen(true);
}, []);
// Obsidian lives in the engines grid for discoverability but routes through
// the unified create flow, pre-set to "link existing → Obsidian".
const openObsidian = useCallback(() => {
setCreatePreset({ mode: "link", source: "obsidian" });
setCreateOpen(true);
}, []);
// Lands on the Overview console unless deep-linked to a KB or an engine.
const [view, setView] = useState<"home" | "kb" | "engine">(
initialEngine ? "engine" : initialKb ? "kb" : "home",
);
const openKb = useCallback((name: string) => {
setExplicitSelection(name);
setView("kb");
}, []);
const openEngine = useCallback((id: string) => {
setSelectedEngineId(id);
setView("engine");
}, []);
// Derive the effective selection: respect the user's pick if it still
// exists, otherwise fall back to the default KB (or the first one). No
// useEffect chains — keeps state out of effects.
const selectedKbName = useMemo<string | null>(() => {
if (explicitSelection && kbs.some((kb) => kb.name === explicitSelection)) {
return explicitSelection;
}
if (!kbs.length) return null;
return kbs.find((kb) => kb.is_default)?.name ?? kbs[0].name;
}, [explicitSelection, kbs]);
const selectedKb = useMemo(
() => kbs.find((kb) => kb.name === selectedKbName) ?? null,
[kbs, selectedKbName],
);
// The effective engine selection: respect the pick if it still exists.
const selectedProvider = useMemo(
() => providers.find((p) => p.id === selectedEngineId) ?? null,
[providers, selectedEngineId],
);
// Keep ?kb / ?engine in sync with the effective selection so deep links work.
// The Overview view carries neither, so reloading the console stays on it.
const urlKb = view === "kb" ? (selectedKbName ?? null) : null;
const urlEngine = view === "engine" ? (selectedProvider?.id ?? null) : null;
useEffect(() => {
if (typeof window === "undefined") return;
if (
searchParams.get("kb") === urlKb &&
searchParams.get("engine") === urlEngine
) {
return;
}
const params = new URLSearchParams(Array.from(searchParams.entries()));
if (urlKb) params.set("kb", urlKb);
else params.delete("kb");
if (urlEngine) params.set("engine", urlEngine);
else params.delete("engine");
const search = params.toString();
router.replace(search ? `?${search}` : "?", { scroll: false });
}, [router, searchParams, urlKb, urlEngine]);
const handleCreate = useCallback(
async (params: { name: string; provider: string; files: File[] }) => {
try {
await createKb(params);
openKb(params.name);
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
throw err;
}
},
[createKb, openKb, setError],
);
const handleSetDefault = useCallback(
async (name: string) => {
try {
await setDefault(name);
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
}
},
[setDefault, setError],
);
const handleDelete = useCallback(
async (name: string) => {
if (!window.confirm(t('Delete knowledge base "{{name}}"?', { name }))) {
return;
}
try {
await deleteKb(name);
if (explicitSelection === name) {
setExplicitSelection(null);
setView("home");
}
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
}
},
[deleteKb, explicitSelection, setError, t],
);
const handleUpload = useCallback(
async (kbName: string, files: File[]) => {
try {
await uploadFiles(kbName, files);
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
throw err;
}
},
[setError, uploadFiles],
);
const handleReindex = useCallback(
async (kbName: string) => {
try {
await reindex(kbName);
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
}
},
[reindex, setError],
);
const handleRetry = useCallback(
async (kbName: string) => {
try {
await retry(kbName);
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
}
},
[retry, setError],
);
const handleSelectMode = useCallback(
async (id: string, mode: string) => {
try {
await updateRagProviderMode(id, mode);
await refresh();
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
}
},
[refresh, setError],
);
return (
<div className="flex h-full flex-col bg-[var(--background)]">
{error && (
<div className="flex items-center justify-between gap-3 border-b border-red-200 bg-red-50 px-4 py-2 text-[12.5px] text-red-700 dark:border-red-900 dark:bg-red-950/30 dark:text-red-300">
<span className="truncate">{error}</span>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => void refresh({ force: true })}
className="rounded-md border border-red-300 px-2 py-0.5 text-[11.5px] font-medium hover:bg-red-100 dark:border-red-900 dark:hover:bg-red-950/50"
>
{t("Retry")}
</button>
<button
type="button"
onClick={() => setError(null)}
className="rounded-md px-2 py-0.5 text-[11.5px] font-medium hover:bg-red-100 dark:hover:bg-red-950/50"
>
{t("Dismiss")}
</button>
</div>
</div>
)}
{loading ? (
<div className="flex flex-1 items-center justify-center">
<Loader2 className="h-5 w-5 animate-spin text-[var(--muted-foreground)]" />
</div>
) : (
<div className="flex min-h-0 flex-1">
{view === "home" ? (
<KnowledgeHome
kbs={kbs}
providers={providers}
onOpenKb={openKb}
onOpenEngine={openEngine}
onCreate={openCreate}
onConnectObsidian={openObsidian}
/>
) : view === "engine" && selectedProvider ? (
<EngineDetail
provider={selectedProvider}
kbs={kbs}
onBack={() => setView("home")}
onOpenKb={openKb}
onSelectMode={handleSelectMode}
onChanged={() => void refresh({ force: true })}
onError={(message) => setError(message)}
/>
) : view === "engine" ? (
// Selected engine vanished (e.g. provider list changed); bounce home.
<KnowledgeHome
kbs={kbs}
providers={providers}
onOpenKb={openKb}
onOpenEngine={openEngine}
onCreate={openCreate}
onConnectObsidian={openObsidian}
/>
) : (
<KnowledgeBaseDetail
kb={selectedKb}
uploadPolicy={uploadPolicy}
task={selectedKb ? tasksByKb[selectedKb.name] : undefined}
history={selectedKb ? (historyByKb[selectedKb.name] ?? []) : []}
onCreate={openCreate}
onUpload={handleUpload}
onReindex={handleReindex}
onRetry={handleRetry}
onSetDefault={handleSetDefault}
onDelete={handleDelete}
onClearHistory={clearHistory}
onBack={() => setView("home")}
/>
)}
</div>
)}
<CreateKbModal
isOpen={createOpen}
onClose={() => setCreateOpen(false)}
providers={providers}
uploadPolicy={uploadPolicy}
onCreate={handleCreate}
onConnectLinkedFolder={connectLinkedFolder}
onConnectObsidian={connectObsidian}
onConnectLightRagServer={connectLightRagServer}
initialMode={createPreset?.mode}
initialSource={createPreset?.source}
onConfigureProvider={() => {
setCreateOpen(false);
setPipelineOpen(true);
}}
/>
<PageIndexSettingsModal
isOpen={pipelineOpen}
onClose={() => setPipelineOpen(false)}
onSaved={() => void refresh({ force: true })}
/>
</div>
);
}
@@ -0,0 +1,197 @@
"use client";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { ExternalLink, KeyRound, Loader2 } from "lucide-react";
import Modal from "@/components/common/Modal";
import {
getPageIndexConfig,
updatePageIndexConfig,
type PageIndexConfig,
} from "@/lib/knowledge-api";
const DEFAULT_BASE_URL = "https://api.pageindex.ai";
interface PageIndexSettingsModalProps {
isOpen: boolean;
onClose: () => void;
/** Called after a successful save so callers can refresh provider state. */
onSaved?: () => void;
}
export default function PageIndexSettingsModal({
isOpen,
onClose,
onSaved,
}: PageIndexSettingsModalProps) {
const { t } = useTranslation();
const [config, setConfig] = useState<PageIndexConfig | null>(null);
const [apiKey, setApiKey] = useState("");
const [baseUrl, setBaseUrl] = useState("");
const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!isOpen) return;
let cancelled = false;
setLoading(true);
setError(null);
setApiKey("");
getPageIndexConfig({ force: true })
.then((cfg) => {
if (cancelled) return;
setConfig(cfg);
setBaseUrl(cfg.api_base_url || "");
})
.catch((err) => {
if (!cancelled)
setError(err instanceof Error ? err.message : String(err));
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, [isOpen]);
const persist = async (payload: {
api_key?: string;
api_base_url?: string;
}) => {
setSaving(true);
setError(null);
try {
const next = await updatePageIndexConfig(payload);
setConfig(next);
setApiKey("");
onSaved?.();
return next;
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
return null;
} finally {
setSaving(false);
}
};
const handleSave = async () => {
// Blank key keeps the stored one; a typed value replaces it.
const payload: { api_key?: string; api_base_url?: string } = {
api_base_url: baseUrl.trim() || undefined,
};
if (apiKey.trim()) payload.api_key = apiKey.trim();
const next = await persist(payload);
if (next) onClose();
};
const keySet = config?.api_key_set ?? false;
return (
<Modal
isOpen={isOpen}
onClose={saving ? () => {} : onClose}
title={t("PageIndex settings")}
titleIcon={<KeyRound size={16} />}
width="md"
closeOnBackdrop={!saving}
closeOnEscape={!saving}
footer={
<div className="flex items-center justify-between gap-2">
<a
href="https://dash.pageindex.ai/api-keys"
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-1 text-[11.5px] text-[var(--muted-foreground)] transition-colors hover:text-[var(--foreground)]"
>
{t("Get an API key")}
<ExternalLink className="h-3 w-3" />
</a>
<div className="flex items-center gap-2">
<button
type="button"
onClick={onClose}
disabled={saving}
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 handleSave()}
disabled={saving || loading}
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"
>
{saving && <Loader2 className="h-3.5 w-3.5 animate-spin" />}
{t("Save")}
</button>
</div>
</div>
}
>
<div className="space-y-4 px-5 py-4">
<p className="text-[12.5px] leading-relaxed text-[var(--muted-foreground)]">
{t(
"PageIndex is a hosted, vectorless retrieval engine. Documents in a PageIndex knowledge base are uploaded to PageIndex's servers for processing. One key is shared by all your PageIndex knowledge bases.",
)}
</p>
{loading ? (
<div className="flex items-center justify-center py-6">
<Loader2 className="h-4 w-4 animate-spin text-[var(--muted-foreground)]" />
</div>
) : (
<>
<div>
<label className="mb-1 block text-[11px] font-medium uppercase tracking-wide text-[var(--muted-foreground)]">
{t("API key")}
</label>
<input
type="password"
value={apiKey}
onChange={(event) => setApiKey(event.target.value)}
disabled={saving}
placeholder={
keySet
? t("•••••••• (configured — leave blank to keep)")
: t("Enter your PageIndex API key")
}
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"
/>
{keySet && (
<button
type="button"
onClick={() => void persist({ api_key: "" })}
disabled={saving}
className="mt-1.5 text-[11px] font-medium text-red-600 transition-colors hover:text-red-700 disabled:opacity-40 dark:text-red-400"
>
{t("Remove stored key")}
</button>
)}
</div>
<div>
<label className="mb-1 block text-[11px] font-medium uppercase tracking-wide text-[var(--muted-foreground)]">
{t("API base URL")}
</label>
<input
value={baseUrl}
onChange={(event) => setBaseUrl(event.target.value)}
disabled={saving}
placeholder={DEFAULT_BASE_URL}
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>
</>
)}
{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">
{error}
</div>
)}
</div>
</Modal>
);
}