adds deleting

This commit is contained in:
2026-02-14 01:10:27 -08:00
parent 6f6dd434af
commit bec25aa943
4 changed files with 165 additions and 5 deletions

View File

@@ -8,6 +8,7 @@ import { ensureDatabaseReady } from "./db-init.js";
import { registerRoutes } from "./routes.js"; import { registerRoutes } from "./routes.js";
const app = Fastify({ const app = Fastify({
disableRequestLogging: true,
logger: { logger: {
transport: { transport: {
target: "pino-pretty", target: "pino-pretty",
@@ -18,7 +19,11 @@ const app = Fastify({
await ensureDatabaseReady(app.log); 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, { await app.register(swagger, {
openapi: { openapi: {
@@ -32,9 +37,39 @@ await app.register(swagger, {
await app.register(swaggerUI, { routePrefix: "/docs" }); await app.register(swaggerUI, { routePrefix: "/docs" });
await app.register(sensible); 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 e = err as any;
const statusCode = e.statusCode ?? 500; 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({ reply.status(statusCode).send({
error: true, error: true,
message: e.message ?? String(e), message: e.message ?? String(e),

View File

@@ -50,7 +50,7 @@ async function storeNonAssistantMessages(chatId: string, messages: IncomingChatM
} }
export async function registerRoutes(app: FastifyInstance) { 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) => { app.get("/v1/auth/session", async (req) => {
requireAdmin(req); requireAdmin(req);
@@ -75,6 +75,23 @@ export async function registerRoutes(app: FastifyInstance) {
return { chat }; 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) => { app.get("/v1/searches", async (req) => {
requireAdmin(req); requireAdmin(req);
const searches = await prisma.search.findMany({ const searches = await prisma.search.findMany({
@@ -101,6 +118,22 @@ export async function registerRoutes(app: FastifyInstance) {
return { search }; 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) => { app.get("/v1/searches/:searchId", async (req) => {
requireAdmin(req); requireAdmin(req);
const Params = z.object({ searchId: z.string() }); const Params = z.object({ searchId: z.string() });

View File

@@ -1,5 +1,5 @@
import { useEffect, useMemo, useRef, useState } from "preact/hooks"; 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 { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
@@ -10,6 +10,8 @@ import { SearchResultsPanel } from "@/components/search/search-results-panel";
import { import {
createChat, createChat,
createSearch, createSearch,
deleteChat,
deleteSearch,
getChat, getChat,
getSearch, getSearch,
listChats, listChats,
@@ -33,6 +35,11 @@ type SidebarItem = SidebarSelection & {
updatedAt: string; updatedAt: string;
createdAt: string; createdAt: string;
}; };
type ContextMenuState = {
item: SidebarSelection;
x: number;
y: number;
};
const PROVIDER_DEFAULT_MODELS: Record<Provider, string> = { const PROVIDER_DEFAULT_MODELS: Record<Provider, string> = {
openai: "gpt-4.1-mini", openai: "gpt-4.1-mini",
@@ -111,6 +118,8 @@ export default function App() {
const [model, setModel] = useState(PROVIDER_DEFAULT_MODELS.openai); const [model, setModel] = useState(PROVIDER_DEFAULT_MODELS.openai);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const transcriptEndRef = useRef<HTMLDivElement>(null); const transcriptEndRef = useRef<HTMLDivElement>(null);
const contextMenuRef = useRef<HTMLDivElement>(null);
const [contextMenu, setContextMenu] = useState<ContextMenuState | null>(null);
const sidebarItems = useMemo(() => buildSidebarItems(chats, searches), [chats, searches]); const sidebarItems = useMemo(() => buildSidebarItems(chats, searches), [chats, searches]);
@@ -263,6 +272,7 @@ export default function App() {
const handleCreateChat = () => { const handleCreateChat = () => {
setError(null); setError(null);
setContextMenu(null);
setDraftKind("chat"); setDraftKind("chat");
setSelectedItem(null); setSelectedItem(null);
setSelectedChat(null); setSelectedChat(null);
@@ -271,12 +281,62 @@ export default function App() {
const handleCreateSearch = () => { const handleCreateSearch = () => {
setError(null); setError(null);
setContextMenu(null);
setDraftKind("search"); setDraftKind("search");
setSelectedItem(null); setSelectedItem(null);
setSelectedChat(null); setSelectedChat(null);
setSelectedSearch(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) => { const handleSendChat = async (content: string) => {
let chatId = draftKind === "chat" ? null : selectedItem?.kind === "chat" ? selectedItem.id : null; let chatId = draftKind === "chat" ? null : selectedItem?.kind === "chat" ? selectedItem.id : null;
@@ -446,6 +506,7 @@ export default function App() {
}; };
const handleLogout = () => { const handleLogout = () => {
setContextMenu(null);
logout(); logout();
resetWorkspaceState(); resetWorkspaceState();
}; };
@@ -503,9 +564,11 @@ export default function App() {
active ? "bg-slate-700 text-slate-50" : "text-slate-200 hover:bg-slate-800" active ? "bg-slate-700 text-slate-50" : "text-slate-200 hover:bg-slate-800"
)} )}
onClick={() => { onClick={() => {
setContextMenu(null);
setDraftKind(null); setDraftKind(null);
setSelectedItem({ kind: item.kind, id: item.id }); setSelectedItem({ kind: item.kind, id: item.id });
}} }}
onContextMenu={(event) => openContextMenu(event, { kind: item.kind, id: item.id })}
type="button" type="button"
> >
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@@ -595,6 +658,24 @@ export default function App() {
</footer> </footer>
</main> </main>
</div> </div>
{contextMenu ? (
<div
ref={contextMenuRef}
className="fixed z-50 min-w-40 rounded-md border border-border bg-background p-1 shadow-md"
style={{ left: contextMenu.x, top: contextMenu.y }}
onContextMenu={(event) => event.preventDefault()}
>
<button
type="button"
className="flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-left text-sm text-red-600 transition hover:bg-muted disabled:text-muted-foreground"
onClick={() => void handleDeleteFromContextMenu()}
disabled={isSending}
>
<Trash2 className="h-3.5 w-3.5" />
Delete
</button>
</div>
) : null}
</div> </div>
); );
} }

View File

@@ -96,7 +96,10 @@ export function setAuthToken(token: string | null) {
async function api<T>(path: string, init?: RequestInit): Promise<T> { async function api<T>(path: string, init?: RequestInit): Promise<T> {
const headers = new Headers(init?.headers ?? {}); const headers = new Headers(init?.headers ?? {});
const hasBody = init?.body !== undefined && init.body !== null;
if (hasBody && !headers.has("Content-Type")) {
headers.set("Content-Type", "application/json"); headers.set("Content-Type", "application/json");
}
if (authToken) { if (authToken) {
headers.set("Authorization", `Bearer ${authToken}`); headers.set("Authorization", `Bearer ${authToken}`);
} }
@@ -143,6 +146,10 @@ export async function getChat(chatId: string) {
return data.chat; return data.chat;
} }
export async function deleteChat(chatId: string) {
await api<{ deleted: true }>(`/v1/chats/${chatId}`, { method: "DELETE" });
}
export async function listSearches() { export async function listSearches() {
const data = await api<{ searches: SearchSummary[] }>("/v1/searches"); const data = await api<{ searches: SearchSummary[] }>("/v1/searches");
return data.searches; return data.searches;
@@ -161,6 +168,10 @@ export async function getSearch(searchId: string) {
return data.search; return data.search;
} }
export async function deleteSearch(searchId: string) {
await api<{ deleted: true }>(`/v1/searches/${searchId}`, { method: "DELETE" });
}
export async function runSearch( export async function runSearch(
searchId: string, searchId: string,
body: { body: {