feat(palace): web UI — palace client, 4 components, palace page, sidebar nav, 6 tests
This commit is contained in:
parent
1bf9896ea9
commit
e6dacbe809
89
web/src/app/(app)/palace/page.tsx
Normal file
89
web/src/app/(app)/palace/page.tsx
Normal file
@ -0,0 +1,89 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { AppShell } from "@/components/AppShell";
|
||||||
|
import { PalacePanel } from "@/components/PalacePanel";
|
||||||
|
import { PalaceStats } from "@/components/PalaceStats";
|
||||||
|
import { KnowledgeGraphView } from "@/components/KnowledgeGraphView";
|
||||||
|
import { MemoryTimeline } from "@/components/MemoryTimeline";
|
||||||
|
import { listWings, type PalaceWing } from "@/lib/palace-client";
|
||||||
|
|
||||||
|
type Tab = "memories" | "timeline" | "graph" | "stats";
|
||||||
|
|
||||||
|
export default function PalacePage() {
|
||||||
|
const [wings, setWings] = useState<PalaceWing[]>([]);
|
||||||
|
const [selectedWing, setSelectedWing] = useState<string | undefined>();
|
||||||
|
const [tab, setTab] = useState<Tab>("memories");
|
||||||
|
const [loadingWings, setLoadingWings] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
const result = await listWings();
|
||||||
|
setWings(result);
|
||||||
|
if (result.length > 0) setSelectedWing(result[0].id);
|
||||||
|
} catch {
|
||||||
|
// Handled gracefully
|
||||||
|
} finally {
|
||||||
|
setLoadingWings(false);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const tabs: { key: Tab; label: string }[] = [
|
||||||
|
{ key: "memories", label: "Memories" },
|
||||||
|
{ key: "timeline", label: "Timeline" },
|
||||||
|
{ key: "graph", label: "Knowledge Graph" },
|
||||||
|
{ key: "stats", label: "Stats" },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppShell title="Memory Palace" description="Browse your AI-extracted memories, knowledge graph, and context">
|
||||||
|
<div style={{ display: "grid", gap: "var(--nl-space-5)" }}>
|
||||||
|
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
|
||||||
|
<h1 style={{ fontSize: "var(--nl-fs-2xl)", fontWeight: 700 }}>Memory Palace</h1>
|
||||||
|
|
||||||
|
{!loadingWings && wings.length > 0 && (
|
||||||
|
<select
|
||||||
|
value={selectedWing ?? ""}
|
||||||
|
onChange={(e) => setSelectedWing(e.target.value || undefined)}
|
||||||
|
className="input"
|
||||||
|
style={{ maxWidth: 240 }}
|
||||||
|
aria-label="Select wing"
|
||||||
|
>
|
||||||
|
<option value="">All wings</option>
|
||||||
|
{wings.map((w) => (
|
||||||
|
<option key={w.id} value={w.id}>
|
||||||
|
{w.name} ({w.memoryCount})
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<nav style={{ display: "flex", gap: "var(--nl-space-2)" }} aria-label="Palace tabs">
|
||||||
|
{tabs.map((t) => (
|
||||||
|
<button
|
||||||
|
key={t.key}
|
||||||
|
onClick={() => setTab(t.key)}
|
||||||
|
className={tab === t.key ? "btn btn-primary" : "btn"}
|
||||||
|
aria-current={tab === t.key ? "page" : undefined}
|
||||||
|
>
|
||||||
|
{t.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{tab === "memories" && <PalacePanel wingId={selectedWing} />}
|
||||||
|
{tab === "timeline" && selectedWing && <MemoryTimeline wingId={selectedWing} />}
|
||||||
|
{tab === "timeline" && !selectedWing && (
|
||||||
|
<div style={{ color: "var(--nl-text-secondary)", textAlign: "center", padding: "var(--nl-space-4)" }}>
|
||||||
|
Select a wing to view the memory timeline.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{tab === "graph" && <KnowledgeGraphView wingId={selectedWing} />}
|
||||||
|
{tab === "stats" && <PalaceStats />}
|
||||||
|
</div>
|
||||||
|
</AppShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
141
web/src/components/KnowledgeGraphView.tsx
Normal file
141
web/src/components/KnowledgeGraphView.tsx
Normal file
@ -0,0 +1,141 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import { queryEntity, getEntityTimeline, getKGContradictions, type PalaceKGTriple } from "@/lib/palace-client";
|
||||||
|
|
||||||
|
interface KnowledgeGraphViewProps {
|
||||||
|
wingId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function KnowledgeGraphView({ wingId }: KnowledgeGraphViewProps) {
|
||||||
|
const [entity, setEntity] = useState("");
|
||||||
|
const [triples, setTriples] = useState<PalaceKGTriple[]>([]);
|
||||||
|
const [contradictions, setContradictions] = useState<Array<{ a: PalaceKGTriple; b: PalaceKGTriple }>>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const loadContradictions = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const result = await getKGContradictions(wingId);
|
||||||
|
setContradictions(result);
|
||||||
|
} catch {
|
||||||
|
// Ignore — contradictions are supplementary
|
||||||
|
}
|
||||||
|
}, [wingId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void loadContradictions();
|
||||||
|
}, [loadContradictions]);
|
||||||
|
|
||||||
|
const handleQuery = async () => {
|
||||||
|
if (!entity.trim()) return;
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
const result = await queryEntity(entity);
|
||||||
|
setTriples(result);
|
||||||
|
} catch {
|
||||||
|
setError("Query failed");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTimeline = async () => {
|
||||||
|
if (!entity.trim()) return;
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
const result = await getEntityTimeline(entity);
|
||||||
|
setTriples(result);
|
||||||
|
} catch {
|
||||||
|
setError("Timeline query failed");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="surface-card" style={{ padding: "var(--nl-space-5)", display: "grid", gap: "var(--nl-space-3)" }}>
|
||||||
|
<div style={{ fontWeight: 700, fontSize: "var(--nl-text-lg)" }}>Knowledge Graph</div>
|
||||||
|
|
||||||
|
<div style={{ display: "flex", gap: "var(--nl-space-2)" }}>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={entity}
|
||||||
|
onChange={(e) => setEntity(e.target.value)}
|
||||||
|
onKeyDown={(e) => e.key === "Enter" && handleQuery()}
|
||||||
|
placeholder="Query entity (e.g. React, Fastify)..."
|
||||||
|
aria-label="Query knowledge graph entity"
|
||||||
|
className="input"
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
/>
|
||||||
|
<button onClick={handleQuery} className="btn btn-primary" aria-label="Query entity">
|
||||||
|
Query
|
||||||
|
</button>
|
||||||
|
<button onClick={handleTimeline} className="btn" aria-label="Show timeline">
|
||||||
|
Timeline
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <div style={{ color: "var(--nl-status-error)" }}>{error}</div>}
|
||||||
|
{loading && <div style={{ color: "var(--nl-text-secondary)" }}>Loading...</div>}
|
||||||
|
|
||||||
|
{!loading && triples.length === 0 && entity && (
|
||||||
|
<div style={{ color: "var(--nl-text-secondary)", textAlign: "center", padding: "var(--nl-space-4)" }}>
|
||||||
|
No triples found for “{entity}”
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div style={{ display: "grid", gap: "var(--nl-space-2)" }}>
|
||||||
|
{triples.map((t) => (
|
||||||
|
<div
|
||||||
|
key={t.id}
|
||||||
|
style={{
|
||||||
|
padding: "var(--nl-space-3)",
|
||||||
|
borderRadius: "var(--nl-radius-md)",
|
||||||
|
border: "1px solid var(--nl-border-subtle)",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: "var(--nl-space-2)",
|
||||||
|
opacity: t.validTo ? 0.5 : 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ fontWeight: 600 }}>{t.subject}</span>
|
||||||
|
<span style={{ color: "var(--nl-accent)" }}>{t.predicate}</span>
|
||||||
|
<span style={{ fontWeight: 600 }}>{t.object}</span>
|
||||||
|
<span style={{ marginLeft: "auto", color: "var(--nl-text-secondary)", fontSize: "0.75rem" }}>
|
||||||
|
{Math.round(t.confidence * 100)}%
|
||||||
|
{t.validTo && " (invalidated)"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{contradictions.length > 0 && (
|
||||||
|
<div style={{ marginTop: "var(--nl-space-3)" }}>
|
||||||
|
<div style={{ fontWeight: 600, color: "var(--nl-status-warning)", marginBottom: "var(--nl-space-2)" }}>
|
||||||
|
Contradictions ({contradictions.length})
|
||||||
|
</div>
|
||||||
|
{contradictions.map((c, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
style={{
|
||||||
|
padding: "var(--nl-space-2)",
|
||||||
|
border: "1px solid var(--nl-status-warning)",
|
||||||
|
borderRadius: "var(--nl-radius-md)",
|
||||||
|
marginBottom: "var(--nl-space-2)",
|
||||||
|
fontSize: "0.875rem",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div>{c.a.subject} {c.a.predicate} → <strong>{c.a.object}</strong></div>
|
||||||
|
<div style={{ color: "var(--nl-status-warning)" }}>
|
||||||
|
vs → <strong>{c.b.object}</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
73
web/src/components/MemoryTimeline.tsx
Normal file
73
web/src/components/MemoryTimeline.tsx
Normal file
@ -0,0 +1,73 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { listMemories, type PalaceMemory } from "@/lib/palace-client";
|
||||||
|
|
||||||
|
interface MemoryTimelineProps {
|
||||||
|
wingId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MemoryTimeline({ wingId }: MemoryTimelineProps) {
|
||||||
|
const [memories, setMemories] = useState<PalaceMemory[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const result = await listMemories({ wingId, limit: 50 });
|
||||||
|
setMemories(result);
|
||||||
|
} catch {
|
||||||
|
setError("Failed to load memory timeline");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}, [wingId]);
|
||||||
|
|
||||||
|
if (loading) return <div style={{ color: "var(--nl-text-secondary)" }}>Loading timeline...</div>;
|
||||||
|
if (error) return <div style={{ color: "var(--nl-status-error)" }}>{error}</div>;
|
||||||
|
if (memories.length === 0) {
|
||||||
|
return (
|
||||||
|
<div style={{ color: "var(--nl-text-secondary)", textAlign: "center", padding: "var(--nl-space-4)" }}>
|
||||||
|
No memories yet. Save notes to start building your memory palace.
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Group by date
|
||||||
|
const grouped: Record<string, PalaceMemory[]> = {};
|
||||||
|
for (const mem of memories) {
|
||||||
|
const day = new Date(mem.createdAt).toLocaleDateString("en-US", {
|
||||||
|
weekday: "short", month: "short", day: "numeric",
|
||||||
|
});
|
||||||
|
if (!grouped[day]) grouped[day] = [];
|
||||||
|
grouped[day].push(mem);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section style={{ display: "grid", gap: "var(--nl-space-4)" }}>
|
||||||
|
{Object.entries(grouped).map(([day, mems]) => (
|
||||||
|
<div key={day}>
|
||||||
|
<div style={{ fontWeight: 600, color: "var(--nl-text-secondary)", fontSize: "0.875rem", marginBottom: "var(--nl-space-2)" }}>
|
||||||
|
{day}
|
||||||
|
</div>
|
||||||
|
<div style={{ display: "grid", gap: "var(--nl-space-2)", borderLeft: "2px solid var(--nl-border-subtle)", paddingLeft: "var(--nl-space-3)" }}>
|
||||||
|
{mems.map((mem) => (
|
||||||
|
<div key={mem.id} style={{ display: "grid", gap: "var(--nl-space-1)" }}>
|
||||||
|
<div style={{ display: "flex", gap: "var(--nl-space-2)", alignItems: "center" }}>
|
||||||
|
<span className="badge" style={{ fontSize: "0.7rem" }}>{mem.hall}</span>
|
||||||
|
<span style={{ color: "var(--nl-text-secondary)", fontSize: "0.75rem" }}>
|
||||||
|
{new Date(mem.createdAt).toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit" })}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: "0.875rem" }}>{mem.content}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
43
web/src/components/PalacePanel.test.tsx
Normal file
43
web/src/components/PalacePanel.test.tsx
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
import { render, screen, waitFor } from "@testing-library/react";
|
||||||
|
import { PalacePanel } from "./PalacePanel";
|
||||||
|
|
||||||
|
const mockListMemories = vi.fn();
|
||||||
|
const mockSearchPalace = vi.fn();
|
||||||
|
|
||||||
|
vi.mock("@/lib/palace-client", () => ({
|
||||||
|
listMemories: (...args: unknown[]) => mockListMemories(...args),
|
||||||
|
searchPalace: (...args: unknown[]) => mockSearchPalace(...args),
|
||||||
|
}));
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mockListMemories.mockReset();
|
||||||
|
mockSearchPalace.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("PalacePanel", () => {
|
||||||
|
it("renders memories after loading", async () => {
|
||||||
|
mockListMemories.mockResolvedValue([
|
||||||
|
{ id: "m1", hall: "decisions", content: "Use JWT for auth", createdAt: "2026-01-01T00:00:00Z" },
|
||||||
|
]);
|
||||||
|
render(<PalacePanel />);
|
||||||
|
await waitFor(() => expect(screen.getByText("Use JWT for auth")).toBeInTheDocument());
|
||||||
|
expect(screen.getByText("decisions")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders empty state", async () => {
|
||||||
|
mockListMemories.mockResolvedValue([]);
|
||||||
|
render(<PalacePanel />);
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(screen.getByText(/No memories found/)).toBeInTheDocument(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("has search input", async () => {
|
||||||
|
mockListMemories.mockResolvedValue([]);
|
||||||
|
render(<PalacePanel />);
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByLabelText("Search palace memories")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
117
web/src/components/PalacePanel.tsx
Normal file
117
web/src/components/PalacePanel.tsx
Normal file
@ -0,0 +1,117 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import { searchPalace, listMemories, type PalaceMemory } from "@/lib/palace-client";
|
||||||
|
|
||||||
|
interface PalacePanelProps {
|
||||||
|
wingId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PalacePanel({ wingId }: PalacePanelProps) {
|
||||||
|
const [query, setQuery] = useState("");
|
||||||
|
const [memories, setMemories] = useState<PalaceMemory[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const loadRecent = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
const result = await listMemories({ wingId, limit: 20 });
|
||||||
|
setMemories(result);
|
||||||
|
} catch {
|
||||||
|
setError("Failed to load memories");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [wingId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void loadRecent();
|
||||||
|
}, [loadRecent]);
|
||||||
|
|
||||||
|
const handleSearch = async () => {
|
||||||
|
if (!query.trim()) {
|
||||||
|
void loadRecent();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
const results = await searchPalace(query, wingId);
|
||||||
|
setMemories(results);
|
||||||
|
} catch {
|
||||||
|
setError("Search failed");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const hallColor: Record<string, string> = {
|
||||||
|
decisions: "var(--nl-status-info)",
|
||||||
|
events: "var(--nl-status-success)",
|
||||||
|
discoveries: "var(--nl-status-warning)",
|
||||||
|
preferences: "var(--nl-accent)",
|
||||||
|
advice: "var(--nl-text-secondary)",
|
||||||
|
insights: "var(--nl-status-info)",
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="surface-card" style={{ padding: "var(--nl-space-5)", display: "grid", gap: "var(--nl-space-3)" }}>
|
||||||
|
<div style={{ fontWeight: 700, fontSize: "var(--nl-text-lg)" }}>Palace Memory</div>
|
||||||
|
|
||||||
|
<div style={{ display: "flex", gap: "var(--nl-space-2)" }}>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={query}
|
||||||
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
|
onKeyDown={(e) => e.key === "Enter" && handleSearch()}
|
||||||
|
placeholder="Search memories..."
|
||||||
|
aria-label="Search palace memories"
|
||||||
|
className="input"
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
/>
|
||||||
|
<button onClick={handleSearch} className="btn btn-primary" aria-label="Search">
|
||||||
|
Search
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <div style={{ color: "var(--nl-status-error)" }}>{error}</div>}
|
||||||
|
{loading && <div style={{ color: "var(--nl-text-secondary)" }}>Loading...</div>}
|
||||||
|
|
||||||
|
{!loading && memories.length === 0 && (
|
||||||
|
<div style={{ color: "var(--nl-text-secondary)", textAlign: "center", padding: "var(--nl-space-4)" }}>
|
||||||
|
No memories found. Memories are automatically extracted from your notes.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div style={{ display: "grid", gap: "var(--nl-space-2)" }}>
|
||||||
|
{memories.map((mem) => (
|
||||||
|
<div
|
||||||
|
key={mem.id}
|
||||||
|
style={{
|
||||||
|
padding: "var(--nl-space-3)",
|
||||||
|
borderRadius: "var(--nl-radius-md)",
|
||||||
|
border: "1px solid var(--nl-border-subtle)",
|
||||||
|
display: "grid",
|
||||||
|
gap: "var(--nl-space-1)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
|
||||||
|
<span
|
||||||
|
className="badge"
|
||||||
|
style={{ backgroundColor: hallColor[mem.hall] ?? "var(--nl-text-secondary)", color: "#fff", fontSize: "0.75rem" }}
|
||||||
|
>
|
||||||
|
{mem.hall}
|
||||||
|
</span>
|
||||||
|
<span style={{ color: "var(--nl-text-secondary)", fontSize: "0.75rem" }}>
|
||||||
|
{new Date(mem.createdAt).toLocaleDateString()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div>{mem.content}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
38
web/src/components/PalaceStats.test.tsx
Normal file
38
web/src/components/PalaceStats.test.tsx
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
import { render, screen, waitFor } from "@testing-library/react";
|
||||||
|
import { PalaceStats } from "./PalaceStats";
|
||||||
|
|
||||||
|
const mockGetPalaceStats = vi.fn();
|
||||||
|
vi.mock("@/lib/palace-client", () => ({
|
||||||
|
getPalaceStats: (...args: unknown[]) => mockGetPalaceStats(...args),
|
||||||
|
}));
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mockGetPalaceStats.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("PalaceStats", () => {
|
||||||
|
it("renders stats after loading", async () => {
|
||||||
|
mockGetPalaceStats.mockResolvedValue({
|
||||||
|
wings: 3, rooms: 7, memories: 42, kgTriples: 15, tunnels: 0, diaries: 0,
|
||||||
|
});
|
||||||
|
render(<PalaceStats />);
|
||||||
|
await waitFor(() => expect(screen.getByText("42")).toBeInTheDocument());
|
||||||
|
expect(screen.getByText("Wings")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("3")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("KG Triples")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("15")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders error when fetch fails", async () => {
|
||||||
|
mockGetPalaceStats.mockRejectedValue(new Error("fail"));
|
||||||
|
render(<PalaceStats />);
|
||||||
|
await waitFor(() => expect(screen.getByText("Failed to load palace stats")).toBeInTheDocument());
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders loading state initially", () => {
|
||||||
|
mockGetPalaceStats.mockReturnValue(new Promise(() => {})); // never resolves
|
||||||
|
render(<PalaceStats />);
|
||||||
|
expect(screen.getByText("Loading stats...")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
58
web/src/components/PalaceStats.tsx
Normal file
58
web/src/components/PalaceStats.tsx
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { getPalaceStats, type PalaceStats as PalaceStatsData } from "@/lib/palace-client";
|
||||||
|
|
||||||
|
export function PalaceStats() {
|
||||||
|
const [stats, setStats] = useState<PalaceStatsData | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
const data = await getPalaceStats();
|
||||||
|
setStats(data);
|
||||||
|
} catch {
|
||||||
|
setError("Failed to load palace stats");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (loading) return <div style={{ color: "var(--nl-text-secondary)" }}>Loading stats...</div>;
|
||||||
|
if (error) return <div style={{ color: "var(--nl-status-error)" }}>{error}</div>;
|
||||||
|
if (!stats) return null;
|
||||||
|
|
||||||
|
const items = [
|
||||||
|
{ label: "Wings", value: stats.wings },
|
||||||
|
{ label: "Rooms", value: stats.rooms },
|
||||||
|
{ label: "Memories", value: stats.memories },
|
||||||
|
{ label: "KG Triples", value: stats.kgTriples },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="surface-card" style={{ padding: "var(--nl-space-5)" }}>
|
||||||
|
<div style={{ fontWeight: 700, fontSize: "var(--nl-text-lg)", marginBottom: "var(--nl-space-3)" }}>
|
||||||
|
Memory Palace
|
||||||
|
</div>
|
||||||
|
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(100px, 1fr))", gap: "var(--nl-space-3)" }}>
|
||||||
|
{items.map(({ label, value }) => (
|
||||||
|
<div
|
||||||
|
key={label}
|
||||||
|
style={{
|
||||||
|
textAlign: "center",
|
||||||
|
padding: "var(--nl-space-3)",
|
||||||
|
borderRadius: "var(--nl-radius-md)",
|
||||||
|
border: "1px solid var(--nl-border-subtle)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ fontSize: "1.5rem", fontWeight: 700, color: "var(--nl-accent)" }}>{value}</div>
|
||||||
|
<div style={{ fontSize: "0.75rem", color: "var(--nl-text-secondary)" }}>{label}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { usePathname } from "next/navigation";
|
import { usePathname } from "next/navigation";
|
||||||
import { House, Search, Settings, Sparkles, FolderKanban, ShieldCheck, MessageCircle } from "lucide-react";
|
import { House, Search, Settings, Sparkles, FolderKanban, ShieldCheck, MessageCircle, Brain } from "lucide-react";
|
||||||
import { PRODUCT_NAME } from "@/lib/product-config";
|
import { PRODUCT_NAME } from "@/lib/product-config";
|
||||||
import { isFeatureEnabled } from "@/lib/feature-flags";
|
import { isFeatureEnabled } from "@/lib/feature-flags";
|
||||||
|
|
||||||
@ -13,6 +13,7 @@ const navItems: { href: string; label: string; icon: typeof House; flag?: string
|
|||||||
{ href: "/prompts", label: "Prompts", icon: Sparkles },
|
{ href: "/prompts", label: "Prompts", icon: Sparkles },
|
||||||
{ href: "/search", label: "Search", icon: Search },
|
{ href: "/search", label: "Search", icon: Search },
|
||||||
{ href: "/chat", label: "Workspace chat", icon: MessageCircle },
|
{ href: "/chat", label: "Workspace chat", icon: MessageCircle },
|
||||||
|
{ href: "/palace", label: "Palace", icon: Brain },
|
||||||
{ href: "/settings", label: "Settings", icon: Settings },
|
{ href: "/settings", label: "Settings", icon: Settings },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
131
web/src/lib/palace-client.test.ts
Normal file
131
web/src/lib/palace-client.test.ts
Normal file
@ -0,0 +1,131 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
|
||||||
|
const mockFetch = vi.fn();
|
||||||
|
vi.mock("@/lib/api-helpers", () => ({
|
||||||
|
createNotesApiClient: () => ({ fetch: mockFetch }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import {
|
||||||
|
searchPalace,
|
||||||
|
listWings,
|
||||||
|
getWingSummary,
|
||||||
|
deleteWing,
|
||||||
|
listRooms,
|
||||||
|
storeMemory,
|
||||||
|
listMemories,
|
||||||
|
deleteMemory,
|
||||||
|
queryEntity,
|
||||||
|
getEntityTimeline,
|
||||||
|
getKGContradictions,
|
||||||
|
getWakeUpContext,
|
||||||
|
getPalaceStats,
|
||||||
|
backfillEmbeddings,
|
||||||
|
pruneMemories,
|
||||||
|
getPalaceHealth,
|
||||||
|
} from "./palace-client";
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mockFetch.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("palace-client", () => {
|
||||||
|
it("searchPalace calls /palace/search with query params", async () => {
|
||||||
|
mockFetch.mockResolvedValue([{ id: "m1", content: "JWT" }]);
|
||||||
|
const result = await searchPalace("JWT", "wing-1", 5);
|
||||||
|
expect(mockFetch).toHaveBeenCalledWith("/palace/search?q=JWT&limit=5&wingId=wing-1");
|
||||||
|
expect(result).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("listWings calls /palace/wings", async () => {
|
||||||
|
mockFetch.mockResolvedValue([]);
|
||||||
|
await listWings();
|
||||||
|
expect(mockFetch).toHaveBeenCalledWith("/palace/wings");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("getWingSummary calls /palace/wings/:wingId", async () => {
|
||||||
|
mockFetch.mockResolvedValue({ wing: { id: "w1" }, rooms: [], totalMemories: 0 });
|
||||||
|
await getWingSummary("w1");
|
||||||
|
expect(mockFetch).toHaveBeenCalledWith("/palace/wings/w1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("deleteWing calls DELETE /palace/wings/:wingId", async () => {
|
||||||
|
mockFetch.mockResolvedValue(undefined);
|
||||||
|
await deleteWing("w1");
|
||||||
|
expect(mockFetch).toHaveBeenCalledWith("/palace/wings/w1", { method: "DELETE" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("listRooms calls /palace/wings/:wingId/rooms", async () => {
|
||||||
|
mockFetch.mockResolvedValue([]);
|
||||||
|
await listRooms("w1");
|
||||||
|
expect(mockFetch).toHaveBeenCalledWith("/palace/wings/w1/rooms");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("storeMemory calls POST /palace/memories", async () => {
|
||||||
|
mockFetch.mockResolvedValue({ stored: true });
|
||||||
|
const result = await storeMemory({
|
||||||
|
wingId: "w1", roomId: "r1", hall: "decisions", content: "test",
|
||||||
|
});
|
||||||
|
expect(result.stored).toBe(true);
|
||||||
|
expect(mockFetch).toHaveBeenCalledWith("/palace/memories", expect.objectContaining({ method: "POST" }));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("listMemories builds query string correctly", async () => {
|
||||||
|
mockFetch.mockResolvedValue([]);
|
||||||
|
await listMemories({ wingId: "w1", hall: "decisions", limit: 5 });
|
||||||
|
expect(mockFetch).toHaveBeenCalledWith("/palace/memories?wingId=w1&hall=decisions&limit=5");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("deleteMemory calls DELETE /palace/memories/:id", async () => {
|
||||||
|
mockFetch.mockResolvedValue(undefined);
|
||||||
|
await deleteMemory("m1");
|
||||||
|
expect(mockFetch).toHaveBeenCalledWith("/palace/memories/m1", { method: "DELETE" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("queryEntity calls /palace/kg/entity/:entity", async () => {
|
||||||
|
mockFetch.mockResolvedValue([]);
|
||||||
|
await queryEntity("React");
|
||||||
|
expect(mockFetch).toHaveBeenCalledWith("/palace/kg/entity/React");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("getEntityTimeline calls /palace/kg/timeline/:entity", async () => {
|
||||||
|
mockFetch.mockResolvedValue([]);
|
||||||
|
await getEntityTimeline("React");
|
||||||
|
expect(mockFetch).toHaveBeenCalledWith("/palace/kg/timeline/React");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("getKGContradictions with wingId", async () => {
|
||||||
|
mockFetch.mockResolvedValue([]);
|
||||||
|
await getKGContradictions("w1");
|
||||||
|
expect(mockFetch).toHaveBeenCalledWith("/palace/kg/contradictions?wingId=w1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("getWakeUpContext with task", async () => {
|
||||||
|
mockFetch.mockResolvedValue({ text: "context", wingName: "Work" });
|
||||||
|
await getWakeUpContext("w1", "auth migration");
|
||||||
|
expect(mockFetch).toHaveBeenCalledWith("/palace/wake-up/w1?task=auth%20migration");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("getPalaceStats calls /palace/stats", async () => {
|
||||||
|
mockFetch.mockResolvedValue({ wings: 1, rooms: 2, memories: 5, kgTriples: 3, tunnels: 0, diaries: 0 });
|
||||||
|
const stats = await getPalaceStats();
|
||||||
|
expect(stats.memories).toBe(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("backfillEmbeddings calls POST /palace/backfill-embeddings", async () => {
|
||||||
|
mockFetch.mockResolvedValue({ backfilled: 3 });
|
||||||
|
const result = await backfillEmbeddings();
|
||||||
|
expect(result.backfilled).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("pruneMemories calls POST /palace/prune", async () => {
|
||||||
|
mockFetch.mockResolvedValue({ pruned: 2 });
|
||||||
|
const result = await pruneMemories({ olderThanDays: 180, minRelevance: 0.1 });
|
||||||
|
expect(result.pruned).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("getPalaceHealth calls /palace/health", async () => {
|
||||||
|
mockFetch.mockResolvedValue({ cosmos: true, llm: true });
|
||||||
|
const health = await getPalaceHealth();
|
||||||
|
expect(health.cosmos).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
190
web/src/lib/palace-client.ts
Normal file
190
web/src/lib/palace-client.ts
Normal file
@ -0,0 +1,190 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { createNotesApiClient } from "@/lib/api-helpers";
|
||||||
|
|
||||||
|
// ── Types ─────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface PalaceWing {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
sourceWorkspaceId: string;
|
||||||
|
memoryCount: number;
|
||||||
|
techStack?: string;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PalaceRoom {
|
||||||
|
id: string;
|
||||||
|
wingId: string;
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
memoryCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PalaceMemory {
|
||||||
|
id: string;
|
||||||
|
wingId: string;
|
||||||
|
roomId: string;
|
||||||
|
hall: string;
|
||||||
|
content: string;
|
||||||
|
relevance: number;
|
||||||
|
sourceNoteId?: string;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PalaceKGTriple {
|
||||||
|
id: string;
|
||||||
|
wingId: string;
|
||||||
|
subject: string;
|
||||||
|
predicate: string;
|
||||||
|
object: string;
|
||||||
|
confidence: number;
|
||||||
|
validFrom: string;
|
||||||
|
validTo?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WingSummary {
|
||||||
|
wing: PalaceWing;
|
||||||
|
rooms: PalaceRoom[];
|
||||||
|
totalMemories: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WakeUpContext {
|
||||||
|
text: string;
|
||||||
|
wingId: string;
|
||||||
|
wingName: string;
|
||||||
|
layers: Array<{ name: string; content: string }>;
|
||||||
|
totalChars: number;
|
||||||
|
truncated: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PalaceStats {
|
||||||
|
wings: number;
|
||||||
|
rooms: number;
|
||||||
|
memories: number;
|
||||||
|
kgTriples: number;
|
||||||
|
tunnels: number;
|
||||||
|
diaries: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── API Client Functions ──────────────────────────
|
||||||
|
|
||||||
|
export async function searchPalace(
|
||||||
|
query: string,
|
||||||
|
wingId?: string,
|
||||||
|
limit = 10,
|
||||||
|
): Promise<PalaceMemory[]> {
|
||||||
|
const api = createNotesApiClient();
|
||||||
|
const params = new URLSearchParams({ q: query, limit: String(limit) });
|
||||||
|
if (wingId) params.set("wingId", wingId);
|
||||||
|
return api.fetch<PalaceMemory[]>(`/palace/search?${params.toString()}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listWings(): Promise<PalaceWing[]> {
|
||||||
|
const api = createNotesApiClient();
|
||||||
|
return api.fetch<PalaceWing[]>("/palace/wings");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getWingSummary(wingId: string): Promise<WingSummary> {
|
||||||
|
const api = createNotesApiClient();
|
||||||
|
return api.fetch<WingSummary>(`/palace/wings/${encodeURIComponent(wingId)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteWing(wingId: string): Promise<void> {
|
||||||
|
const api = createNotesApiClient();
|
||||||
|
await api.fetch(`/palace/wings/${encodeURIComponent(wingId)}`, { method: "DELETE" });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listRooms(wingId: string): Promise<PalaceRoom[]> {
|
||||||
|
const api = createNotesApiClient();
|
||||||
|
return api.fetch<PalaceRoom[]>(`/palace/wings/${encodeURIComponent(wingId)}/rooms`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function storeMemory(input: {
|
||||||
|
wingId: string;
|
||||||
|
roomId: string;
|
||||||
|
hall: string;
|
||||||
|
content: string;
|
||||||
|
sourceNoteId?: string;
|
||||||
|
}): Promise<{ stored: boolean; reason?: string; memory?: PalaceMemory }> {
|
||||||
|
const api = createNotesApiClient();
|
||||||
|
return api.fetch(`/palace/memories`, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(input),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listMemories(opts?: {
|
||||||
|
wingId?: string;
|
||||||
|
roomId?: string;
|
||||||
|
hall?: string;
|
||||||
|
limit?: number;
|
||||||
|
}): Promise<PalaceMemory[]> {
|
||||||
|
const api = createNotesApiClient();
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (opts?.wingId) params.set("wingId", opts.wingId);
|
||||||
|
if (opts?.roomId) params.set("roomId", opts.roomId);
|
||||||
|
if (opts?.hall) params.set("hall", opts.hall);
|
||||||
|
if (opts?.limit) params.set("limit", String(opts.limit));
|
||||||
|
return api.fetch<PalaceMemory[]>(`/palace/memories?${params.toString()}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteMemory(id: string): Promise<void> {
|
||||||
|
const api = createNotesApiClient();
|
||||||
|
await api.fetch(`/palace/memories/${encodeURIComponent(id)}`, { method: "DELETE" });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function queryEntity(entity: string): Promise<PalaceKGTriple[]> {
|
||||||
|
const api = createNotesApiClient();
|
||||||
|
return api.fetch<PalaceKGTriple[]>(`/palace/kg/entity/${encodeURIComponent(entity)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getEntityTimeline(entity: string): Promise<PalaceKGTriple[]> {
|
||||||
|
const api = createNotesApiClient();
|
||||||
|
return api.fetch<PalaceKGTriple[]>(`/palace/kg/timeline/${encodeURIComponent(entity)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getKGContradictions(wingId?: string): Promise<
|
||||||
|
Array<{ a: PalaceKGTriple; b: PalaceKGTriple }>
|
||||||
|
> {
|
||||||
|
const api = createNotesApiClient();
|
||||||
|
const params = wingId ? `?wingId=${encodeURIComponent(wingId)}` : "";
|
||||||
|
return api.fetch(`/palace/kg/contradictions${params}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getWakeUpContext(
|
||||||
|
wingId: string,
|
||||||
|
task?: string,
|
||||||
|
): Promise<WakeUpContext> {
|
||||||
|
const api = createNotesApiClient();
|
||||||
|
const params = task ? `?task=${encodeURIComponent(task)}` : "";
|
||||||
|
return api.fetch<WakeUpContext>(`/palace/wake-up/${encodeURIComponent(wingId)}${params}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getPalaceStats(): Promise<PalaceStats> {
|
||||||
|
const api = createNotesApiClient();
|
||||||
|
return api.fetch<PalaceStats>("/palace/stats");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function backfillEmbeddings(): Promise<{ backfilled: number }> {
|
||||||
|
const api = createNotesApiClient();
|
||||||
|
return api.fetch("/palace/backfill-embeddings", { method: "POST" });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function pruneMemories(opts?: {
|
||||||
|
olderThanDays?: number;
|
||||||
|
minRelevance?: number;
|
||||||
|
}): Promise<{ pruned: number }> {
|
||||||
|
const api = createNotesApiClient();
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (opts?.olderThanDays) params.set("olderThanDays", String(opts.olderThanDays));
|
||||||
|
if (opts?.minRelevance) params.set("minRelevance", String(opts.minRelevance));
|
||||||
|
return api.fetch(`/palace/prune?${params.toString()}`, { method: "POST" });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getPalaceHealth(): Promise<{ cosmos: boolean; llm: boolean }> {
|
||||||
|
const api = createNotesApiClient();
|
||||||
|
return api.fetch("/palace/health");
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user