adds deleting
This commit is contained in:
@@ -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),
|
||||
|
||||
@@ -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() });
|
||||
|
||||
@@ -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<Provider, string> = {
|
||||
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<string | null>(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]);
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -595,6 +658,24 @@ export default function App() {
|
||||
</footer>
|
||||
</main>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -96,7 +96,10 @@ export function setAuthToken(token: string | null) {
|
||||
|
||||
async function api<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
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");
|
||||
}
|
||||
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: {
|
||||
|
||||
Reference in New Issue
Block a user