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

266 lines
8.7 KiB
TypeScript

"use client";
import Link from "next/link";
import { useCallback, useEffect, useState } from "react";
import {
ArrowRight,
Brain,
Layers,
Network,
RefreshCw,
Sparkles,
Workflow,
type LucideIcon,
} from "lucide-react";
import { useTranslation } from "react-i18next";
import { apiFetch, apiUrl } from "@/lib/api";
import MemoryArchivedBanner from "@/components/memory/MemoryArchivedBanner";
interface DocOverview {
layer: "L2" | "L3";
key: string;
exists: boolean;
updated_at: string | null;
entry_count: number;
backlog: number;
}
interface OverviewResponse {
docs: DocOverview[];
backups: string[];
}
interface SnapshotResponse {
surface: string;
entities: unknown[];
}
const SURFACES = [
"chat",
"notebook",
"quiz",
"kb",
"book",
"partner",
"cowriter",
] as const;
const L3_VISIBLE = ["recent", "profile", "scope"] as const;
export default function MemoryHub() {
const { t } = useTranslation();
const [overview, setOverview] = useState<OverviewResponse | null>(null);
const [l1Total, setL1Total] = useState<number | null>(null);
const [loading, setLoading] = useState(true);
const load = useCallback(async () => {
setLoading(true);
try {
const [ovRes, ...l1Counts] = await Promise.all([
apiFetch(apiUrl("/api/v1/memory/overview")).then((r) => r.json()),
...SURFACES.map((s) =>
apiFetch(apiUrl(`/api/v1/memory/snapshot/${s}`))
.then((r) => r.json())
.then((d: SnapshotResponse) => d?.entities?.length ?? 0)
.catch(() => 0),
),
]);
setOverview(ovRes as OverviewResponse);
setL1Total(l1Counts.reduce<number>((acc, n) => acc + (n as number), 0));
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
void load();
}, [load]);
const l2Docs = (overview?.docs || []).filter((d) => d.layer === "L2");
const l3Docs = (overview?.docs || []).filter(
(d) => d.layer === "L3" && d.key !== "preferences",
);
const l2Total = l2Docs.reduce((acc, d) => acc + d.entry_count, 0);
const l3Total = l3Docs.reduce((acc, d) => acc + d.entry_count, 0);
const latestBackup = overview?.backups?.[overview.backups.length - 1] ?? null;
return (
<div className="space-y-10">
<header className="space-y-3">
<div className="flex items-center gap-3">
<span className="grid h-10 w-10 place-items-center rounded-xl bg-[var(--primary)]/10 text-[var(--primary)]">
<Brain className="h-5 w-5" />
</span>
<h1 className="font-serif text-[28px] font-semibold tracking-tight text-[var(--foreground)] md:text-[32px]">
{t("Memory")}
</h1>
</div>
<p className="max-w-2xl text-[14px] text-[var(--muted-foreground)] md:text-[15px]">
{t(
"Everything DeepTutor remembers about you, organised across three layers. Click into any layer to inspect or curate it.",
)}
</p>
<div className="flex items-center gap-3 text-[12px] text-[var(--muted-foreground)]">
<button
type="button"
onClick={() => void load()}
className="inline-flex items-center gap-1.5 rounded-md border border-[var(--border)] bg-[var(--background)] px-2.5 py-1 transition hover:bg-[var(--muted)]"
>
<RefreshCw
className={loading ? "h-3 w-3 animate-spin" : "h-3 w-3"}
/>
{t("Refresh")}
</button>
<Link
href="/settings/memory"
className="inline-flex items-center gap-1.5 rounded-md border border-transparent px-2.5 py-1 transition hover:bg-[var(--muted)]"
>
{t("Memory settings")}
</Link>
</div>
</header>
<div className="grid grid-cols-1 gap-5 md:grid-cols-3">
<LayerCard
href="/memory/l1"
icon={Layers}
title={t("L1 · Workspace mirror")}
tag={t("Live")}
stat={l1Total === null ? "…" : l1Total.toLocaleString()}
statLabel={t("entities tracked")}
detail={t(
"Snapshot of your live workspace across {{n}} surfaces. Refresh to record changes.",
{ n: SURFACES.length },
)}
/>
<LayerCard
href="/memory/l2"
icon={Workflow}
title={t("L2 · Per-surface summaries")}
tag={t("Curated")}
stat={l2Total.toLocaleString()}
statLabel={t("facts across {{n}} surfaces", {
n: l2Docs.length || SURFACES.length,
})}
detail={t(
"Surface-specific facts extracted by the consolidator. Run Update / Audit / Dedup per doc.",
)}
/>
<LayerCard
href="/memory/l3"
icon={Network}
title={t("L3 · Cross-surface knowledge")}
tag={t("Synthesis")}
stat={l3Total.toLocaleString()}
statLabel={t("propositions across {{n}} slots", {
n: l3Docs.length || L3_VISIBLE.length,
})}
detail={t(
"Cross-surface synthesis: profile, recent timeline, knowledge scope. Hedged claims with L2 evidence.",
)}
/>
</div>
<GraphCallout />
<MemoryArchivedBanner latestBackup={latestBackup} variant="compact" />
</div>
);
}
function GraphCallout() {
const { t } = useTranslation();
return (
<Link
href="/memory/graph"
className="group relative block overflow-hidden rounded-2xl border border-[var(--border)] bg-[var(--card)] p-6 transition hover:-translate-y-[1px] hover:border-[var(--primary)]/40 hover:shadow-sm"
>
<div
className="pointer-events-none absolute inset-0 opacity-80"
style={{
background:
"radial-gradient(ellipse 60% 80% at 92% 50%, color-mix(in srgb, var(--primary) 16%, transparent), transparent 70%)",
}}
/>
<div className="relative flex items-center gap-5">
<span className="grid h-12 w-12 shrink-0 place-items-center rounded-xl bg-[var(--primary)]/10 text-[var(--primary)]">
<Sparkles className="h-5 w-5" />
</span>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<h3 className="text-[15px] font-semibold text-[var(--foreground)]">
{t("Memory graph")}
</h3>
<span className="rounded-full border border-[var(--border)] bg-[var(--background)]/60 px-2 py-0.5 text-[10.5px] font-medium uppercase tracking-wide text-[var(--muted-foreground)]">
{t("New")}
</span>
</div>
<p className="mt-1 text-[13px] leading-relaxed text-[var(--muted-foreground)]">
{t(
"See all three layers at once — L3 synthesis at the centre, L2 facts in the middle, L1 traces on the outside. Hover any node for a preview.",
)}
</p>
</div>
<ArrowRight className="hidden h-4 w-4 shrink-0 text-[var(--primary)] transition group-hover:translate-x-0.5 md:block" />
</div>
</Link>
);
}
interface LayerCardProps {
href: string;
icon: LucideIcon;
title: string;
tag: string;
stat: string;
statLabel: string;
detail: string;
}
function LayerCard({
href,
icon: Icon,
title,
tag,
stat,
statLabel,
detail,
}: LayerCardProps) {
return (
<Link
href={href}
className="group flex flex-col gap-4 rounded-2xl border border-[var(--border)] bg-[var(--card)] p-6 transition hover:-translate-y-[1px] hover:border-[var(--primary)]/40 hover:shadow-sm"
>
<div className="flex items-start justify-between">
<span className="grid h-9 w-9 place-items-center rounded-lg bg-[var(--primary)]/10 text-[var(--primary)]">
<Icon className="h-4 w-4" />
</span>
<span className="rounded-full border border-[var(--border)] bg-[var(--background)] px-2 py-0.5 text-[10.5px] font-medium uppercase tracking-wide text-[var(--muted-foreground)]">
{tag}
</span>
</div>
<div className="space-y-1">
<h2 className="text-[15px] font-semibold text-[var(--foreground)]">
{title}
</h2>
</div>
<div className="flex items-baseline gap-2">
<span className="text-[28px] font-semibold tracking-tight text-[var(--foreground)]">
{stat}
</span>
<span className="text-[12px] text-[var(--muted-foreground)]">
{statLabel}
</span>
</div>
<p className="text-[13px] leading-relaxed text-[var(--muted-foreground)]">
{detail}
</p>
<div className="mt-auto inline-flex items-center gap-1 text-[12px] font-medium text-[var(--primary)] opacity-0 transition group-hover:opacity-100">
<span>{tag}</span>
<ArrowRight className="h-3.5 w-3.5" />
</div>
</Link>
);
}