From bec25aa943fe993a99fa8aebd299441ef5c28368 Mon Sep 17 00:00:00 2001 From: James Magahern Date: Sat, 14 Feb 2026 01:10:27 -0800 Subject: [PATCH] adds deleting --- server/src/index.ts | 39 +++++++++++++++++++-- server/src/routes.ts | 35 ++++++++++++++++++- web/src/App.tsx | 83 +++++++++++++++++++++++++++++++++++++++++++- web/src/lib/api.ts | 13 ++++++- 4 files changed, 165 insertions(+), 5 deletions(-) diff --git a/server/src/index.ts b/server/src/index.ts index 07c9bd9..7b40dab 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -8,6 +8,7 @@ import { ensureDatabaseReady } from "./db-init.js"; import { registerRoutes } from "./routes.js"; const app = Fastify({ + disableRequestLogging: true, logger: { transport: { target: "pino-pretty", @@ -18,7 +19,11 @@ const app = Fastify({ await ensureDatabaseReady(app.log); -await app.register(cors, { origin: true, credentials: true }); +await app.register(cors, { + origin: true, + credentials: true, + methods: ["GET", "HEAD", "POST", "DELETE", "OPTIONS"], +}); await app.register(swagger, { openapi: { @@ -32,9 +37,39 @@ await app.register(swagger, { await app.register(swaggerUI, { routePrefix: "/docs" }); await app.register(sensible); -app.setErrorHandler((err, _req, reply) => { +app.addHook("onRequest", async (req) => { + if (req.url.startsWith("/health")) return; + req.log.info({ method: req.method, url: req.url }, "request received"); +}); + +app.addHook("onResponse", async (req, reply) => { + if (req.url.startsWith("/health")) return; + const responseTimeMs = typeof reply.elapsedTime === "number" ? Number(reply.elapsedTime.toFixed(1)) : undefined; + req.log.info( + { + method: req.method, + url: req.url, + statusCode: reply.statusCode, + responseTimeMs, + }, + "request completed" + ); +}); + +app.setErrorHandler((err, req, reply) => { const e = err as any; const statusCode = e.statusCode ?? 500; + if (!req.url.startsWith("/health")) { + req.log.error( + { + method: req.method, + url: req.url, + statusCode, + err: e, + }, + "request failed" + ); + } reply.status(statusCode).send({ error: true, message: e.message ?? String(e), diff --git a/server/src/routes.ts b/server/src/routes.ts index 495fb4b..59a2dfa 100644 --- a/server/src/routes.ts +++ b/server/src/routes.ts @@ -50,7 +50,7 @@ async function storeNonAssistantMessages(chatId: string, messages: IncomingChatM } export async function registerRoutes(app: FastifyInstance) { - app.get("/health", async () => ({ ok: true })); + app.get("/health", { logLevel: "silent" }, async () => ({ ok: true })); app.get("/v1/auth/session", async (req) => { requireAdmin(req); @@ -75,6 +75,23 @@ export async function registerRoutes(app: FastifyInstance) { return { chat }; }); + app.delete("/v1/chats/:chatId", async (req) => { + requireAdmin(req); + const Params = z.object({ chatId: z.string() }); + const { chatId } = Params.parse(req.params); + + req.log.info({ chatId }, "delete chat requested"); + + const result = await prisma.chat.deleteMany({ where: { id: chatId } }); + if (result.count === 0) { + req.log.warn({ chatId }, "delete chat target not found"); + return app.httpErrors.notFound("chat not found"); + } + + req.log.info({ chatId }, "chat deleted"); + return { deleted: true }; + }); + app.get("/v1/searches", async (req) => { requireAdmin(req); const searches = await prisma.search.findMany({ @@ -101,6 +118,22 @@ export async function registerRoutes(app: FastifyInstance) { return { search }; }); + app.delete("/v1/searches/:searchId", async (req) => { + requireAdmin(req); + const Params = z.object({ searchId: z.string() }); + const { searchId } = Params.parse(req.params); + + req.log.info({ searchId }, "delete search requested"); + + const result = await prisma.search.deleteMany({ where: { id: searchId } }); + if (result.count === 0) { + req.log.warn({ searchId }, "delete search target not found"); + return app.httpErrors.notFound("search not found"); + } + req.log.info({ searchId }, "search deleted"); + return { deleted: true }; + }); + app.get("/v1/searches/:searchId", async (req) => { requireAdmin(req); const Params = z.object({ searchId: z.string() }); diff --git a/web/src/App.tsx b/web/src/App.tsx index 782f0e1..a4eba44 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -1,5 +1,5 @@ import { useEffect, useMemo, useRef, useState } from "preact/hooks"; -import { Globe2, LogOut, MessageSquare, Plus, Search, SendHorizontal } from "lucide-preact"; +import { Globe2, LogOut, MessageSquare, Plus, Search, SendHorizontal, Trash2 } from "lucide-preact"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Textarea } from "@/components/ui/textarea"; @@ -10,6 +10,8 @@ import { SearchResultsPanel } from "@/components/search/search-results-panel"; import { createChat, createSearch, + deleteChat, + deleteSearch, getChat, getSearch, listChats, @@ -33,6 +35,11 @@ type SidebarItem = SidebarSelection & { updatedAt: string; createdAt: string; }; +type ContextMenuState = { + item: SidebarSelection; + x: number; + y: number; +}; const PROVIDER_DEFAULT_MODELS: Record = { openai: "gpt-4.1-mini", @@ -111,6 +118,8 @@ export default function App() { const [model, setModel] = useState(PROVIDER_DEFAULT_MODELS.openai); const [error, setError] = useState(null); const transcriptEndRef = useRef(null); + const contextMenuRef = useRef(null); + const [contextMenu, setContextMenu] = useState(null); const sidebarItems = useMemo(() => buildSidebarItems(chats, searches), [chats, searches]); @@ -263,6 +272,7 @@ export default function App() { const handleCreateChat = () => { setError(null); + setContextMenu(null); setDraftKind("chat"); setSelectedItem(null); setSelectedChat(null); @@ -271,12 +281,62 @@ export default function App() { const handleCreateSearch = () => { setError(null); + setContextMenu(null); setDraftKind("search"); setSelectedItem(null); setSelectedChat(null); setSelectedSearch(null); }; + const openContextMenu = (event: MouseEvent, item: SidebarSelection) => { + event.preventDefault(); + const menuWidth = 160; + const menuHeight = 40; + const padding = 8; + const x = Math.min(event.clientX, window.innerWidth - menuWidth - padding); + const y = Math.min(event.clientY, window.innerHeight - menuHeight - padding); + setContextMenu({ item, x: Math.max(padding, x), y: Math.max(padding, y) }); + }; + + const handleDeleteFromContextMenu = async () => { + if (!contextMenu || isSending) return; + const target = contextMenu.item; + setContextMenu(null); + setError(null); + try { + if (target.kind === "chat") { + await deleteChat(target.id); + } else { + await deleteSearch(target.id); + } + await refreshCollections(); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + if (message.includes("bearer token")) { + handleAuthFailure(message); + } else { + setError(message); + } + } + }; + + useEffect(() => { + if (!contextMenu) return; + const handlePointerDown = (event: PointerEvent) => { + if (contextMenuRef.current?.contains(event.target as Node)) return; + setContextMenu(null); + }; + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") setContextMenu(null); + }; + window.addEventListener("pointerdown", handlePointerDown); + window.addEventListener("keydown", handleKeyDown); + return () => { + window.removeEventListener("pointerdown", handlePointerDown); + window.removeEventListener("keydown", handleKeyDown); + }; + }, [contextMenu]); + const handleSendChat = async (content: string) => { let chatId = draftKind === "chat" ? null : selectedItem?.kind === "chat" ? selectedItem.id : null; @@ -446,6 +506,7 @@ export default function App() { }; const handleLogout = () => { + setContextMenu(null); logout(); resetWorkspaceState(); }; @@ -503,9 +564,11 @@ export default function App() { active ? "bg-slate-700 text-slate-50" : "text-slate-200 hover:bg-slate-800" )} onClick={() => { + setContextMenu(null); setDraftKind(null); setSelectedItem({ kind: item.kind, id: item.id }); }} + onContextMenu={(event) => openContextMenu(event, { kind: item.kind, id: item.id })} type="button" >
@@ -595,6 +658,24 @@ export default function App() {
+ {contextMenu ? ( +
event.preventDefault()} + > + +
+ ) : null} ); } diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index eb712ff..7db620d 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -96,7 +96,10 @@ export function setAuthToken(token: string | null) { async function api(path: string, init?: RequestInit): Promise { const headers = new Headers(init?.headers ?? {}); - headers.set("Content-Type", "application/json"); + const hasBody = init?.body !== undefined && init.body !== null; + if (hasBody && !headers.has("Content-Type")) { + headers.set("Content-Type", "application/json"); + } if (authToken) { headers.set("Authorization", `Bearer ${authToken}`); } @@ -143,6 +146,10 @@ export async function getChat(chatId: string) { return data.chat; } +export async function deleteChat(chatId: string) { + await api<{ deleted: true }>(`/v1/chats/${chatId}`, { method: "DELETE" }); +} + export async function listSearches() { const data = await api<{ searches: SearchSummary[] }>("/v1/searches"); return data.searches; @@ -161,6 +168,10 @@ export async function getSearch(searchId: string) { return data.search; } +export async function deleteSearch(searchId: string) { + await api<{ deleted: true }>(`/v1/searches/${searchId}`, { method: "DELETE" }); +} + export async function runSearch( searchId: string, body: {