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

332 lines
13 KiB
TypeScript

"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>
);
}