adds deleting
This commit is contained in:
@@ -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),
|
||||||
|
|||||||
@@ -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() });
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
Reference in New Issue
Block a user