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 { 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 { 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: "/search", label: "Search", icon: Search },
|
||||
{ href: "/chat", label: "Workspace chat", icon: MessageCircle },
|
||||
{ href: "/palace", label: "Palace", icon: Brain },
|
||||
{ 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