"use client"; import { useCallback, useEffect, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; import { ArrowLeft, Boxes, Check, CheckCircle2, ChevronDown, CircleSlash, Cloud, Copy, Database, ExternalLink, KeyRound, Loader2, Network, RefreshCw, Server, Settings2, ShieldCheck, Workflow, XCircle, type LucideIcon, } from "lucide-react"; import Link from "next/link"; import { getEngineModelOptions, getEnginePreflight, getGraphRagConfig, getLightRagConfig, getLlamaIndexConfig, getPageIndexConfig, setEngineActiveModel, updateGraphRagConfig, updateLightRagConfig, updateLlamaIndexConfig, updatePageIndexConfig, type EnginePreflight, type GraphRagConfig, type LightRagConfig, type LlamaIndexConfig, type ModelOptionsByKind, type PageIndexConfig, type RagProviderSummary, } from "@/lib/knowledge-api"; import { kbDocCount, kbProvider, resolveKbStatus, type KnowledgeBase, } from "@/lib/knowledge-helpers"; interface EngineDetailProps { provider: RagProviderSummary; kbs: KnowledgeBase[]; onBack: () => void; onOpenKb: (name: string) => void; onSelectMode: (providerId: string, mode: string) => Promise | void; /** Called after a config change so the parent can refresh provider state. */ onChanged: () => void; onError: (message: string) => void; } const ENGINE_ICONS: Record = { llamaindex: Boxes, pageindex: Cloud, graphrag: Network, lightrag: Workflow, "lightrag-server": Server, }; const INSTALL_HINTS: Record = { graphrag: "pip install 'deeptutor[graphrag]'", lightrag: "pip install 'deeptutor[rag-lightrag]'", }; // Mode one-liners, keyed by `${engineId}:${mode}`. English source strings double // as i18n keys (zh translations live in locales/zh/app.json). const MODE_DESCRIPTIONS: Record = { "graphrag:local": "Entity-focused retrieval over the relevant local subgraph — fast and economical.", "graphrag:global": "Map-reduce over global community summaries — best for broad, thematic questions.", "graphrag:drift": "Local retrieval with dynamic follow-ups — balances precision and coverage.", "graphrag:basic": "Plain vector retrieval — the lightest option.", "lightrag:naive": "Plain vector retrieval, without the knowledge graph.", "lightrag:local": "Local context focused on the most relevant entities.", "lightrag:global": "Theme-level retrieval over global relationships.", "lightrag:hybrid": "Combines local and global retrieval — a solid general default.", "lightrag:mix": "Fuses knowledge-graph and vector retrieval.", "lightrag-server:naive": "Plain vector retrieval, without the knowledge graph.", "lightrag-server:local": "Local context focused on the most relevant entities.", "lightrag-server:global": "Theme-level retrieval over global relationships.", "lightrag-server:hybrid": "Combines local and global retrieval — a solid general default.", "lightrag-server:mix": "Fuses knowledge-graph and vector retrieval — the server's default.", }; // Model kinds each engine needs (for the in-place pickers). "vision" isn't a // catalog service — it rides on the active chat model — so LightRAG only lists // llm + embedding and shows a vision note under the chat picker. LightRAG Server // owns its own models on the remote instance, so it needs none here. const ENGINE_MODEL_KINDS: Record = { llamaindex: ["embedding"], pageindex: [], graphrag: ["llm", "embedding"], lightrag: ["llm", "embedding"], "lightrag-server": [], }; const MODEL_KIND_LABEL: Record = { llm: "Chat model", embedding: "Embedding model", }; // Free-form GraphRAG/LightRAG answer styles. Any string is accepted server-side; // these are the common presets. const RESPONSE_TYPE_PRESETS = [ "Multiple Paragraphs", "Single Paragraph", "Single Sentence", "List of 3-7 Points", "Multiple-Page Report", ]; // Prerequisites prose per engine (English source doubles as i18n key). const ENGINE_PREREQUISITES: Record = { llamaindex: "Local vector engine — works out of the box. Retrieval uses your active embedding model; install the optional BM25 package to enable hybrid retrieval.", pageindex: "Hosted engine: documents are uploaded to PageIndex's servers for processing. Requires an API key; PDF / Markdown only.", graphrag: "Local knowledge-graph retrieval. Needs the optional dependency installed; indexing is LLM-heavy. Requires an active chat model and embedding model.", lightrag: "Graph + vector retrieval with multimodal parsing. Needs the optional dependency installed; indexing is LLM-heavy. Requires active chat and embedding models; multimodal also needs a vision model.", }; type EngineStatus = "ready" | "needs_key" | "unavailable"; function resolveStatus(provider: RagProviderSummary): EngineStatus { if (provider.requires_api_key && provider.configured === false) return "needs_key"; if (provider.configured === false) return "unavailable"; return "ready"; } function StatusBadge({ status }: { status: EngineStatus }) { const { t } = useTranslation(); if (status === "ready") { return ( {t("Ready")} ); } if (status === "needs_key") { return ( {t("Needs key")} ); } return ( {t("Not installed")} ); } /** Section shell: small uppercase label + bordered card body. */ function Section({ label, icon: Icon, children, }: { label: string; icon: LucideIcon; children: React.ReactNode; }) { return (

{label}

{children}
); } function CopyableCommand({ command }: { command: string }) { const { t } = useTranslation(); const [copied, setCopied] = useState(false); return (
{command}
); } /** Number field used by the LlamaIndex tuning form. */ function NumberField({ label, hint, value, min, max, onChange, disabled, }: { label: string; hint?: string; value: number; min: number; max: number; onChange: (next: number) => void; disabled?: boolean; }) { return ( ); } /* ----------------------------- Mode selector ----------------------------- */ function ModeSelector({ provider, onSelectMode, }: { provider: RagProviderSummary; onSelectMode: (providerId: string, mode: string) => Promise | void; }) { const { t } = useTranslation(); const modes = useMemo(() => provider.modes ?? [], [provider.modes]); const [selected, setSelected] = useState( provider.default_mode || modes[0] || "", ); const [pending, setPending] = useState(null); useEffect(() => { setSelected(provider.default_mode || modes[0] || ""); }, [provider.default_mode, modes]); const pick = async (mode: string) => { if (mode === selected) return; setSelected(mode); setPending(mode); try { await onSelectMode(provider.id, mode); } finally { setPending(null); } }; return (
{modes.map((mode) => { const active = mode === selected; const desc = MODE_DESCRIPTIONS[`${provider.id}:${mode}`]; return ( ); })}
); } /* -------------------------- LlamaIndex config form ------------------------ */ function LlamaIndexForm({ onChanged, onError, }: { onChanged: () => void; onError: (message: string) => void; }) { const { t } = useTranslation(); const [loaded, setLoaded] = useState(null); const [form, setForm] = useState(null); const [saving, setSaving] = useState(false); useEffect(() => { let cancelled = false; getLlamaIndexConfig({ force: true }) .then((cfg) => { if (cancelled) return; setLoaded(cfg); setForm(cfg); }) .catch((err) => onError(err instanceof Error ? err.message : String(err)), ); return () => { cancelled = true; }; // onError is stable enough; we only want this on mount. // eslint-disable-next-line react-hooks/exhaustive-deps }, []); const dirty = useMemo( () => !!form && !!loaded && JSON.stringify(form) !== JSON.stringify(loaded), [form, loaded], ); if (!form) { return (
); } const set = (patch: Partial) => setForm((prev) => (prev ? { ...prev, ...patch } : prev)); const save = async () => { if (!form) return; setSaving(true); try { const next = await updateLlamaIndexConfig({ retrieval_profile: form.retrieval_profile, top_k: form.top_k, vector_top_k_multiplier: form.vector_top_k_multiplier, bm25_top_k_multiplier: form.bm25_top_k_multiplier, chunk_size: form.chunk_size, chunk_overlap: form.chunk_overlap, }); setLoaded(next); setForm(next); onChanged(); } catch (err) { onError(err instanceof Error ? err.message : String(err)); } finally { setSaving(false); } }; const isHybrid = form.retrieval_profile === "hybrid"; const profiles: { id: LlamaIndexConfig["retrieval_profile"]; desc: string; }[] = [ { id: "hybrid", desc: "BM25 keyword + vector semantic retrieval, fused and re-ranked. More robust recall.", }, { id: "vector", desc: "Vector semantic retrieval only. Faster, but leans entirely on embedding quality.", }, ]; return (
{/* Retrieval profile */}
{t("Retrieval profile")}
{profiles.map((p) => { const active = form.retrieval_profile === p.id; return ( ); })}
{/* Retrieval breadth */}
set({ top_k: v })} /> set({ vector_top_k_multiplier: v })} /> set({ bm25_top_k_multiplier: v })} />
{/* Chunking */}
{t("Chunking")} {t("Applies on the next re-index")}
set({ chunk_size: v })} /> set({ chunk_overlap: v })} />
); } /* -------------------------- PageIndex config form ------------------------- */ const PAGEINDEX_DEFAULT_BASE_URL = "https://api.pageindex.ai"; function PageIndexForm({ onChanged, onError, }: { onChanged: () => void; onError: (message: string) => void; }) { const { t } = useTranslation(); const [config, setConfig] = useState(null); const [apiKey, setApiKey] = useState(""); const [baseUrl, setBaseUrl] = useState(""); const [saving, setSaving] = useState(false); useEffect(() => { let cancelled = false; getPageIndexConfig({ force: true }) .then((cfg) => { if (cancelled) return; setConfig(cfg); setBaseUrl(cfg.api_base_url || ""); }) .catch((err) => onError(err instanceof Error ? err.message : String(err)), ); return () => { cancelled = true; }; // eslint-disable-next-line react-hooks/exhaustive-deps }, []); const persist = async (payload: { api_key?: string; api_base_url?: string; }) => { setSaving(true); try { const next = await updatePageIndexConfig(payload); setConfig(next); setApiKey(""); onChanged(); } catch (err) { onError(err instanceof Error ? err.message : String(err)); } finally { setSaving(false); } }; const keySet = config?.api_key_set ?? false; return (

{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.", )}

setApiKey(e.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 && ( )}
setBaseUrl(e.target.value)} disabled={saving} placeholder={PAGEINDEX_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" />
{t("Get an API key")}
); } /* --------------------------- Shared form controls ------------------------- */ function ResponseTypeSelect({ value, onChange, }: { value: string; onChange: (next: string) => void; }) { const { t } = useTranslation(); const known = RESPONSE_TYPE_PRESETS.includes(value); return ( ); } function ToggleField({ label, hint, checked, onChange, }: { label: string; hint?: string; checked: boolean; onChange: (next: boolean) => void; }) { return (
{label} {hint && ( {hint} )}
); } function SaveButton({ dirty, saving, onSave, }: { dirty: boolean; saving: boolean; onSave: () => void; }) { const { t } = useTranslation(); return (
); } /** Shared loader + dirty-tracking scaffold for the small engine config forms. */ function useEngineForm( load: () => Promise, onError: (m: string) => void, ) { const [loaded, setLoaded] = useState(null); const [form, setForm] = useState(null); const [saving, setSaving] = useState(false); useEffect(() => { let cancelled = false; load() .then((cfg) => { if (cancelled) return; setLoaded(cfg); setForm(cfg); }) .catch((err) => onError(err instanceof Error ? err.message : String(err)), ); return () => { cancelled = true; }; // eslint-disable-next-line react-hooks/exhaustive-deps }, []); const dirty = useMemo( () => !!form && !!loaded && JSON.stringify(form) !== JSON.stringify(loaded), [form, loaded], ); const patch = (p: Partial) => setForm((prev) => (prev ? { ...prev, ...p } : prev)); return { loaded, form, setLoaded, setForm, saving, setSaving, dirty, patch }; } /* -------------------------- GraphRAG config form -------------------------- */ function GraphRagForm({ onChanged, onError, }: { onChanged: () => void; onError: (message: string) => void; }) { const { t } = useTranslation(); const { form, setLoaded, setForm, saving, setSaving, dirty, patch } = useEngineForm( () => getGraphRagConfig({ force: true }), onError, ); if (!form) return ; const save = async () => { setSaving(true); try { const next = await updateGraphRagConfig({ response_type: form.response_type, community_level: form.community_level, dynamic_community_selection: form.dynamic_community_selection, }); setLoaded(next); setForm(next); onChanged(); } catch (err) { onError(err instanceof Error ? err.message : String(err)); } finally { setSaving(false); } }; return (
patch({ response_type: v })} /> patch({ community_level: v })} />
patch({ dynamic_community_selection: v })} /> void save()} />
); } /* -------------------------- LightRAG config form -------------------------- */ function LightRagForm({ onChanged, onError, }: { onChanged: () => void; onError: (message: string) => void; }) { const { t } = useTranslation(); const { form, setLoaded, setForm, saving, setSaving, dirty, patch } = useEngineForm( () => getLightRagConfig({ force: true }), onError, ); if (!form) return ; const save = async () => { setSaving(true); try { const next = await updateLightRagConfig({ top_k: form.top_k, response_type: form.response_type, }); setLoaded(next); setForm(next); onChanged(); } catch (err) { onError(err instanceof Error ? err.message : String(err)); } finally { setSaving(false); } }; return (
patch({ top_k: v })} /> patch({ response_type: v })} />
void save()} />
); } function FormSkeleton() { return (
); } /* ------------------------------ Model pickers ----------------------------- */ function ModelsSection({ providerId, onError, }: { providerId: string; onError: (message: string) => void; }) { const { t } = useTranslation(); const kinds = useMemo( () => ENGINE_MODEL_KINDS[providerId] ?? [], [providerId], ); const [data, setData] = useState(null); const [failed, setFailed] = useState(false); const [busyKind, setBusyKind] = useState(null); useEffect(() => { if (kinds.length === 0) return; let cancelled = false; getEngineModelOptions(kinds) .then((d) => !cancelled && setData(d)) .catch(() => !cancelled && setFailed(true)); return () => { cancelled = true; }; }, [kinds]); const select = useCallback( async (kind: string, profileId: string, modelId: string) => { setBusyKind(kind); try { const updated = await setEngineActiveModel(kind, profileId, modelId); setData((prev) => (prev ? { ...prev, [kind]: updated } : prev)); } catch (err) { onError(err instanceof Error ? err.message : String(err)); } finally { setBusyKind(null); } }, [onError], ); if (kinds.length === 0) return null; return (
{failed ? (

{t( "This engine uses your active chat and embedding models. Manage them in the model catalog.", )}

{t("Open catalog")}
) : !data ? ( ) : (
{kinds.map((kind) => { const entry = data[kind]; const value = `${entry?.active.profile_id ?? ""}::${entry?.active.model_id ?? ""}`; const hasOptions = (entry?.options.length ?? 0) > 0; return (
{t(MODEL_KIND_LABEL[kind] ?? kind)} {busyKind === kind && ( )}
{hasOptions ? ( ) : ( {t("No models configured — open catalog")} )} {kind === "llm" && providerId === "lightrag" && ( {t( "Multimodal documents need a vision-capable chat model.", )} )}
); })}
)}
); } /* ----------------------- Requirements & environment ----------------------- */ function CheckRow({ ok, optional, label, detail, }: { ok: boolean; optional: boolean; label: string; detail: string; }) { const { t } = useTranslation(); const Icon = ok ? CheckCircle2 : optional ? CircleSlash : XCircle; const tone = ok ? "text-emerald-600 dark:text-emerald-400" : optional ? "text-[var(--muted-foreground)]" : "text-red-600 dark:text-red-400"; return (
  • {t(label)} {optional && ( ({t("optional")}) )} {detail && (
    {detail}
    )}
  • ); } function EnvRequirements({ providerId, installHint, defaultOpen, onError, }: { providerId: string; installHint?: string; defaultOpen: boolean; onError: (message: string) => void; }) { const { t } = useTranslation(); const [open, setOpen] = useState(defaultOpen); const [report, setReport] = useState(null); const [checking, setChecking] = useState(false); const prereq = ENGINE_PREREQUISITES[providerId]; const runCheck = async () => { setChecking(true); try { setReport(await getEnginePreflight(providerId)); } catch (err) { onError(err instanceof Error ? err.message : String(err)); } finally { setChecking(false); } }; return (
    {open && (
    {prereq && (

    {t(prereq)}

    )} {installHint && }
    {report && ( {report.ok ? t("Ready to use") : t("Not ready")} )}
    {report && (
      {report.checks.map((c) => ( ))}
    )}
    )}
    ); } /* ------------------------------ Main component ---------------------------- */ export default function EngineDetail({ provider, kbs, onBack, onOpenKb, onSelectMode, onChanged, onError, }: EngineDetailProps) { const { t } = useTranslation(); const status = resolveStatus(provider); const Icon = ENGINE_ICONS[provider.id] ?? Boxes; const installHint = INSTALL_HINTS[provider.id]; const hasModes = (provider.modes?.length ?? 0) > 0; const engineKbs = useMemo( () => kbs.filter((kb) => kbProvider(kb) === provider.id), [kbs, provider.id], ); return (
    {/* Header */}

    {provider.name}

    {provider.description}

    {/* Requirements & environment — collapsible, unified across engines. Auto-opens when the engine isn't ready so the gap is obvious. */} {/* Retrieval modes (graphrag / lightrag) */} {hasModes && (

    {t( "The default for new searches. A knowledge base can still override it per-KB.", )}

    )} {/* LlamaIndex tuning */} {provider.id === "llamaindex" && (
    )} {/* GraphRAG query knobs */} {provider.id === "graphrag" && (
    )} {/* LightRAG query knobs */} {provider.id === "lightrag" && (
    )} {/* PageIndex credentials */} {provider.id === "pageindex" && (
    )} {/* Models — in-place pickers for the kinds this engine needs */} {/* Knowledge bases on this engine */}
    {engineKbs.length === 0 ? (
    {t("No knowledge bases use this engine yet.")}
    ) : (
    {engineKbs.map((kb, i) => { const docs = kbDocCount(kb); const ready = resolveKbStatus(kb) === "ready"; return ( ); })}
    )}
    ); }