feat: Phase 3b — proper Web UI for memories, projects, settings
Replaces the debug /me + /connect pages with a real authed app shell.
Pages
- / — anonymous landing; redirects to /dashboard once signed in
- /dashboard — recent memories + top projects, quick "new memory" action
- /memories — searchable list with hybrid (vector+FTS+tags) scoring;
per-result rank breakdown shown inline
- /memories/[id] — view + inline edit toggle + delete
- /memories/new — create form with project autocomplete
- /projects — list with memory counts and last-activity
- /projects/[key] — that project's memories
- /settings — read-only Authentik profile + link to tokens
- /settings/tokens — list / create / revoke CLI tokens
Old URLs preserved as redirects:
- /me → /dashboard
- /connect → /settings/tokens
Stack additions
- Tailwind v4 with CSS-first @theme tokens (dark only for now)
- App shell in app/(authed)/ — auth guard + top nav with global search box
- Lightweight UI primitives in app/_components/ui/ (Button, Input, Card,
Badge, EmptyState, Container, PageHeader)
- Search logic extracted from MCP tool into lib/memories.ts so Web UI and
MCP both call the same RRF code path
- Memory CRUD via Server Actions in lib/memory-actions.ts; audit_log
rows are tagged actor='web' to distinguish from MCP writes
Per-token revoke
- New cli_tokens table (id, user_id, jti unique, name, created_at,
last_used_at, expires_at, revoked_at) — migration 0001_cli_tokens.sql
- mintCliToken now records jti + name; verifyCliToken enforces revocation
for tracked tokens. Legacy tokens minted before this change (no jti)
are accepted on signature alone until they expire naturally.
- /settings/tokens lists active + revoked tokens with one-click revoke
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
+16
-108
@@ -1,5 +1,5 @@
|
||||
import { and, desc, eq, inArray, isNull, sql } from "drizzle-orm";
|
||||
import { db, pg } from "@/lib/db/client";
|
||||
import { db } from "@/lib/db/client";
|
||||
import { memories, projects, auditLog } from "@/lib/db/schema";
|
||||
import {
|
||||
MemoryIdInput,
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
ProjectIdentifyInput,
|
||||
} from "@shared-memory/schemas";
|
||||
import { embedText } from "@/lib/embedder";
|
||||
import { searchMemories } from "@/lib/memories";
|
||||
import type { UserContext } from "./context";
|
||||
|
||||
/**
|
||||
@@ -62,11 +63,6 @@ async function resolveProjectId(
|
||||
return row[0]?.id ?? null;
|
||||
}
|
||||
|
||||
/** pgvector accepts vectors as text literals like "[0.1,0.2,...]". */
|
||||
function toVectorLiteral(v: number[]): string {
|
||||
return `[${v.join(",")}]`;
|
||||
}
|
||||
|
||||
// ---------- tools ----------
|
||||
|
||||
const projectIdentify: ToolDef = {
|
||||
@@ -368,13 +364,6 @@ const memoryUpdate: ToolDef = {
|
||||
},
|
||||
};
|
||||
|
||||
interface RankAccumulator {
|
||||
vectorRank?: number;
|
||||
ftsRank?: number;
|
||||
tagRank?: number;
|
||||
rrfScore: number;
|
||||
}
|
||||
|
||||
const memorySearch: ToolDef = {
|
||||
name: "memory.search",
|
||||
description:
|
||||
@@ -399,87 +388,21 @@ const memorySearch: ToolDef = {
|
||||
? await resolveProjectId(ctx, parsed.data.project)
|
||||
: null;
|
||||
if (parsed.data.project && !projectId) {
|
||||
return ok({ items: [], _ranks: {} }, "0 results (unknown project)");
|
||||
return ok({ items: [], debug: { vec: 0, fts: 0, tag: 0 } }, "0 results (unknown project)");
|
||||
}
|
||||
|
||||
const queryVec = await embedText(query);
|
||||
const vecLit = toVectorLiteral(queryVec);
|
||||
const CANDIDATES = 50;
|
||||
const RRF_K = 60;
|
||||
const result = await searchMemories(
|
||||
ctx.userId,
|
||||
query,
|
||||
{ scope, projectKey: parsed.data.project, tags },
|
||||
limit,
|
||||
);
|
||||
|
||||
// Run the three candidate-fetch queries in parallel. The filter is
|
||||
// expressed via pg's tagged-template binding so values are safely
|
||||
// interpolated.
|
||||
const userId = ctx.userId;
|
||||
|
||||
const vecPromise = pg<{ id: string }[]>`
|
||||
SELECT id
|
||||
FROM memories
|
||||
WHERE user_id = ${userId}
|
||||
AND deleted_at IS NULL
|
||||
AND embedding IS NOT NULL
|
||||
${scope ? pg`AND scope = ${scope}` : pg``}
|
||||
${projectId ? pg`AND project_id = ${projectId}` : pg``}
|
||||
ORDER BY embedding <=> ${vecLit}::vector ASC
|
||||
LIMIT ${CANDIDATES}
|
||||
`;
|
||||
|
||||
const ftsPromise = pg<{ id: string }[]>`
|
||||
SELECT id
|
||||
FROM memories, plainto_tsquery('english', ${query}) AS q
|
||||
WHERE user_id = ${userId}
|
||||
AND deleted_at IS NULL
|
||||
AND content_tsv @@ q
|
||||
${scope ? pg`AND scope = ${scope}` : pg``}
|
||||
${projectId ? pg`AND project_id = ${projectId}` : pg``}
|
||||
ORDER BY ts_rank_cd(content_tsv, q) DESC
|
||||
LIMIT ${CANDIDATES}
|
||||
`;
|
||||
|
||||
const tagPromise =
|
||||
tags && tags.length > 0
|
||||
? pg<{ id: string }[]>`
|
||||
SELECT id
|
||||
FROM memories
|
||||
WHERE user_id = ${userId}
|
||||
AND deleted_at IS NULL
|
||||
AND tags && ${tags}::text[]
|
||||
${scope ? pg`AND scope = ${scope}` : pg``}
|
||||
${projectId ? pg`AND project_id = ${projectId}` : pg``}
|
||||
ORDER BY cardinality(
|
||||
ARRAY(SELECT unnest(tags) INTERSECT SELECT unnest(${tags}::text[]))
|
||||
) DESC
|
||||
LIMIT ${CANDIDATES}
|
||||
`
|
||||
: Promise.resolve([] as { id: string }[]);
|
||||
|
||||
const [vecHits, ftsHits, tagHits] = await Promise.all([
|
||||
vecPromise,
|
||||
ftsPromise,
|
||||
tagPromise,
|
||||
]);
|
||||
|
||||
// Fuse via RRF: score(d) = Σ_r 1/(k + rank_r(d))
|
||||
const scores = new Map<string, RankAccumulator>();
|
||||
const accum = (id: string, rank: number, key: "vectorRank" | "ftsRank" | "tagRank") => {
|
||||
const e = scores.get(id) ?? { rrfScore: 0 };
|
||||
e[key] = rank;
|
||||
e.rrfScore += 1 / (RRF_K + rank);
|
||||
scores.set(id, e);
|
||||
};
|
||||
vecHits.forEach((h, i) => accum(h.id, i + 1, "vectorRank"));
|
||||
ftsHits.forEach((h, i) => accum(h.id, i + 1, "ftsRank"));
|
||||
tagHits.forEach((h, i) => accum(h.id, i + 1, "tagRank"));
|
||||
|
||||
if (scores.size === 0) {
|
||||
return ok({ items: [], debug: { vec: 0, fts: 0, tag: 0 } }, "0 results");
|
||||
if (result.hits.length === 0) {
|
||||
return ok({ items: [], debug: result.debug }, "0 results");
|
||||
}
|
||||
|
||||
const sorted = [...scores.entries()]
|
||||
.sort(([, a], [, b]) => b.rrfScore - a.rrfScore)
|
||||
.slice(0, limit);
|
||||
const topIds = sorted.map(([id]) => id);
|
||||
|
||||
const topIds = result.hits.map((h) => h.id);
|
||||
const rows = await db
|
||||
.select({
|
||||
id: memories.id,
|
||||
@@ -494,33 +417,18 @@ const memorySearch: ToolDef = {
|
||||
.where(inArray(memories.id, topIds));
|
||||
|
||||
const byId = new Map(rows.map((r) => [r.id, r]));
|
||||
const items = sorted.flatMap(([id, rank]) => {
|
||||
const row = byId.get(id);
|
||||
const items = result.hits.flatMap((hit) => {
|
||||
const row = byId.get(hit.id);
|
||||
if (!row) return [];
|
||||
return [
|
||||
{
|
||||
...row,
|
||||
_rank: {
|
||||
rrfScore: Number(rank.rrfScore.toFixed(6)),
|
||||
vectorRank: rank.vectorRank ?? null,
|
||||
ftsRank: rank.ftsRank ?? null,
|
||||
tagRank: rank.tagRank ?? null,
|
||||
},
|
||||
_rank: hit.rank,
|
||||
},
|
||||
];
|
||||
});
|
||||
|
||||
return ok(
|
||||
{
|
||||
items,
|
||||
debug: {
|
||||
vec: vecHits.length,
|
||||
fts: ftsHits.length,
|
||||
tag: tagHits.length,
|
||||
},
|
||||
},
|
||||
`${items.length} result(s)`,
|
||||
);
|
||||
return ok({ items, debug: result.debug }, `${items.length} result(s)`);
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user