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
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:
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user