feat: optimistic delete UX; tags history API+UI; Qdrant API (collections/points) + page; loading skeleton; deps (framer-motion, skeleton, qdrant, deck.gl, umap). Implements next steps from implementation_plan.md
This commit is contained in:
parent
bc49eec7c8
commit
7df19c6696
2741
package-lock.json
generated
2741
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -12,6 +12,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@codemirror/lang-markdown": "^6.3.4",
|
"@codemirror/lang-markdown": "^6.3.4",
|
||||||
"@elastic/elasticsearch": "^9.1.1",
|
"@elastic/elasticsearch": "^9.1.1",
|
||||||
|
"@qdrant/qdrant-js": "^1.15.1",
|
||||||
"@radix-ui/react-dialog": "^1.1.15",
|
"@radix-ui/react-dialog": "^1.1.15",
|
||||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||||
"@radix-ui/react-progress": "^1.1.7",
|
"@radix-ui/react-progress": "^1.1.7",
|
||||||
@ -24,14 +25,18 @@
|
|||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
|
"deck.gl": "^9.1.14",
|
||||||
|
"framer-motion": "^12.23.12",
|
||||||
"lucide-react": "^0.544.0",
|
"lucide-react": "^0.544.0",
|
||||||
"next": "15.5.3",
|
"next": "15.5.3",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
"openai": "^5.20.2",
|
"openai": "^5.20.2",
|
||||||
"react": "19.1.0",
|
"react": "19.1.0",
|
||||||
"react-dom": "19.1.0",
|
"react-dom": "19.1.0",
|
||||||
|
"react-loading-skeleton": "^3.5.0",
|
||||||
"sonner": "^2.0.7",
|
"sonner": "^2.0.7",
|
||||||
"tailwind-merge": "^3.3.1",
|
"tailwind-merge": "^3.3.1",
|
||||||
|
"umap-js": "^1.4.0",
|
||||||
"webdav": "^5.8.0",
|
"webdav": "^5.8.0",
|
||||||
"zod": "^3.25.76"
|
"zod": "^3.25.76"
|
||||||
},
|
},
|
||||||
|
|||||||
39
src/app/api/qdrant/collections/route.ts
Normal file
39
src/app/api/qdrant/collections/route.ts
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
import * as Sentry from "@sentry/nextjs";
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { listCollections } from "@/lib/qdrant";
|
||||||
|
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
function json<T>(data: T, init?: { status?: number } & ResponseInit) {
|
||||||
|
return NextResponse.json(data, init);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
const collections = await Sentry.startSpan(
|
||||||
|
{ op: "function", name: "api.qdrant.collections" },
|
||||||
|
async () => {
|
||||||
|
const all = await listCollections();
|
||||||
|
// Normalize shape for client
|
||||||
|
type QdrantCollection = {
|
||||||
|
name: string;
|
||||||
|
vectors_count?: number;
|
||||||
|
points_count?: number;
|
||||||
|
};
|
||||||
|
const list = (all as unknown as QdrantCollection[]).map((c) => ({
|
||||||
|
name: c.name,
|
||||||
|
vectors_count: c.vectors_count,
|
||||||
|
points_count: c.points_count,
|
||||||
|
}));
|
||||||
|
return list;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
return json({ collections });
|
||||||
|
} catch (error: unknown) {
|
||||||
|
Sentry.captureException(error);
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
return json({ error: "Failed to list Qdrant collections", message }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
50
src/app/api/qdrant/points/route.ts
Normal file
50
src/app/api/qdrant/points/route.ts
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
import * as Sentry from "@sentry/nextjs";
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { listPoints } from "@/lib/qdrant";
|
||||||
|
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
function json<T>(data: T, init?: { status?: number } & ResponseInit) {
|
||||||
|
return NextResponse.json(data, init);
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /api/qdrant/points?collection=fortura-db&limit=100&offset=...&withVector=false
|
||||||
|
export async function GET(req: Request) {
|
||||||
|
try {
|
||||||
|
const { searchParams } = new URL(req.url);
|
||||||
|
const collection = searchParams.get("collection") ?? undefined;
|
||||||
|
const limitParam = searchParams.get("limit");
|
||||||
|
const offsetParam = searchParams.get("offset");
|
||||||
|
const withVectorParam = searchParams.get("withVector");
|
||||||
|
|
||||||
|
if (!collection) {
|
||||||
|
return json({ error: "collection is required" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const limit = Math.min(Math.max(Number(limitParam || 100) || 100, 1), 1000);
|
||||||
|
const withVector = withVectorParam === "true";
|
||||||
|
|
||||||
|
const result = await Sentry.startSpan(
|
||||||
|
{ op: "function", name: "api.qdrant.points" },
|
||||||
|
async (span) => {
|
||||||
|
span.setAttribute("collection", collection);
|
||||||
|
span.setAttribute("limit", limit);
|
||||||
|
span.setAttribute("withVector", withVector);
|
||||||
|
const res = await listPoints({
|
||||||
|
collection,
|
||||||
|
limit,
|
||||||
|
offset: offsetParam,
|
||||||
|
withVector,
|
||||||
|
});
|
||||||
|
return res;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
return json(result);
|
||||||
|
} catch (error: unknown) {
|
||||||
|
Sentry.captureException(error);
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
return json({ error: "Failed to list Qdrant points", message }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -44,74 +44,76 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
--radius: 0.625rem;
|
--radius: 0.65rem;
|
||||||
--background: oklch(1 0 0);
|
--background: oklch(1 0 0);
|
||||||
--foreground: oklch(0.129 0.042 264.695);
|
--foreground: oklch(0.145 0 0);
|
||||||
--card: oklch(1 0 0);
|
--card: oklch(1 0 0);
|
||||||
--card-foreground: oklch(0.129 0.042 264.695);
|
--card-foreground: oklch(0.145 0 0);
|
||||||
--popover: oklch(1 0 0);
|
--popover: oklch(1 0 0);
|
||||||
--popover-foreground: oklch(0.129 0.042 264.695);
|
--popover-foreground: oklch(0.145 0 0);
|
||||||
--primary: oklch(0.208 0.042 265.755);
|
--primary: oklch(0.205 0 0);
|
||||||
--primary-foreground: oklch(0.984 0.003 247.858);
|
--primary-foreground: oklch(0.985 0 0);
|
||||||
--secondary: oklch(0.968 0.007 247.896);
|
--secondary: oklch(0.97 0 0);
|
||||||
--secondary-foreground: oklch(0.208 0.042 265.755);
|
--secondary-foreground: oklch(0.205 0 0);
|
||||||
--muted: oklch(0.968 0.007 247.896);
|
--muted: oklch(0.97 0 0);
|
||||||
--muted-foreground: oklch(0.554 0.046 257.417);
|
--muted-foreground: oklch(0.556 0 0);
|
||||||
--accent: oklch(0.968 0.007 247.896);
|
--accent: oklch(0.97 0 0);
|
||||||
--accent-foreground: oklch(0.208 0.042 265.755);
|
--accent-foreground: oklch(0.205 0 0);
|
||||||
--destructive: oklch(0.577 0.245 27.325);
|
--destructive: oklch(0.577 0.245 27.325);
|
||||||
--border: oklch(0.929 0.013 255.508);
|
--border: oklch(0.922 0 0);
|
||||||
--input: oklch(0.929 0.013 255.508);
|
--input: oklch(0.922 0 0);
|
||||||
--ring: oklch(0.704 0.04 256.788);
|
--ring: oklch(0.708 0 0);
|
||||||
--chart-1: oklch(0.646 0.222 41.116);
|
--chart-1: oklch(0.646 0.222 41.116);
|
||||||
--chart-2: oklch(0.6 0.118 184.704);
|
--chart-2: oklch(0.6 0.118 184.704);
|
||||||
--chart-3: oklch(0.398 0.07 227.392);
|
--chart-3: oklch(0.398 0.07 227.392);
|
||||||
--chart-4: oklch(0.828 0.189 84.429);
|
--chart-4: oklch(0.828 0.189 84.429);
|
||||||
--chart-5: oklch(0.769 0.188 70.08);
|
--chart-5: oklch(0.769 0.188 70.08);
|
||||||
--sidebar: oklch(0.984 0.003 247.858);
|
--radius: 0.625rem;
|
||||||
--sidebar-foreground: oklch(0.129 0.042 264.695);
|
--sidebar: oklch(0.985 0 0);
|
||||||
--sidebar-primary: oklch(0.208 0.042 265.755);
|
--sidebar-foreground: oklch(0.145 0 0);
|
||||||
--sidebar-primary-foreground: oklch(0.984 0.003 247.858);
|
--sidebar-primary: oklch(0.205 0 0);
|
||||||
--sidebar-accent: oklch(0.968 0.007 247.896);
|
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||||
--sidebar-accent-foreground: oklch(0.208 0.042 265.755);
|
--sidebar-accent: oklch(0.97 0 0);
|
||||||
--sidebar-border: oklch(0.929 0.013 255.508);
|
--sidebar-accent-foreground: oklch(0.205 0 0);
|
||||||
--sidebar-ring: oklch(0.704 0.04 256.788);
|
--sidebar-border: oklch(0.922 0 0);
|
||||||
|
--sidebar-ring: oklch(0.708 0 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark {
|
.dark {
|
||||||
--background: oklch(0.129 0.042 264.695);
|
--background: oklch(0.145 0 0);
|
||||||
--foreground: oklch(0.984 0.003 247.858);
|
--foreground: oklch(0.985 0 0);
|
||||||
--card: oklch(0.208 0.042 265.755);
|
--card: oklch(0.205 0 0);
|
||||||
--card-foreground: oklch(0.984 0.003 247.858);
|
--card-foreground: oklch(0.985 0 0);
|
||||||
--popover: oklch(0.208 0.042 265.755);
|
--popover: oklch(0.205 0 0);
|
||||||
--popover-foreground: oklch(0.984 0.003 247.858);
|
--popover-foreground: oklch(0.985 0 0);
|
||||||
--primary: oklch(0.929 0.013 255.508);
|
--primary: oklch(0.922 0 0);
|
||||||
--primary-foreground: oklch(0.208 0.042 265.755);
|
--primary-foreground: oklch(0.205 0 0);
|
||||||
--secondary: oklch(0.279 0.041 260.031);
|
--secondary: oklch(0.269 0 0);
|
||||||
--secondary-foreground: oklch(0.984 0.003 247.858);
|
--secondary-foreground: oklch(0.985 0 0);
|
||||||
--muted: oklch(0.279 0.041 260.031);
|
--muted: oklch(0.269 0 0);
|
||||||
--muted-foreground: oklch(0.704 0.04 256.788);
|
--muted-foreground: oklch(0.708 0 0);
|
||||||
--accent: oklch(0.279 0.041 260.031);
|
--accent: oklch(0.269 0 0);
|
||||||
--accent-foreground: oklch(0.984 0.003 247.858);
|
--accent-foreground: oklch(0.985 0 0);
|
||||||
--destructive: oklch(0.704 0.191 22.216);
|
--destructive: oklch(0.704 0.191 22.216);
|
||||||
--border: oklch(1 0 0 / 10%);
|
--border: oklch(1 0 0 / 10%);
|
||||||
--input: oklch(1 0 0 / 15%);
|
--input: oklch(1 0 0 / 15%);
|
||||||
--ring: oklch(0.551 0.027 264.364);
|
--ring: oklch(0.556 0 0);
|
||||||
--chart-1: oklch(0.488 0.243 264.376);
|
--chart-1: oklch(0.488 0.243 264.376);
|
||||||
--chart-2: oklch(0.696 0.17 162.48);
|
--chart-2: oklch(0.696 0.17 162.48);
|
||||||
--chart-3: oklch(0.769 0.188 70.08);
|
--chart-3: oklch(0.769 0.188 70.08);
|
||||||
--chart-4: oklch(0.627 0.265 303.9);
|
--chart-4: oklch(0.627 0.265 303.9);
|
||||||
--chart-5: oklch(0.645 0.246 16.439);
|
--chart-5: oklch(0.645 0.246 16.439);
|
||||||
--sidebar: oklch(0.208 0.042 265.755);
|
--sidebar: oklch(0.205 0 0);
|
||||||
--sidebar-foreground: oklch(0.984 0.003 247.858);
|
--sidebar-foreground: oklch(0.985 0 0);
|
||||||
--sidebar-primary: oklch(0.488 0.243 264.376);
|
--sidebar-primary: oklch(0.488 0.243 264.376);
|
||||||
--sidebar-primary-foreground: oklch(0.984 0.003 247.858);
|
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||||
--sidebar-accent: oklch(0.279 0.041 260.031);
|
--sidebar-accent: oklch(0.269 0 0);
|
||||||
--sidebar-accent-foreground: oklch(0.984 0.003 247.858);
|
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||||
--sidebar-border: oklch(1 0 0 / 10%);
|
--sidebar-border: oklch(1 0 0 / 10%);
|
||||||
--sidebar-ring: oklch(0.551 0.027 264.364);
|
--sidebar-ring: oklch(0.556 0 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@layer base {
|
@layer base {
|
||||||
* {
|
* {
|
||||||
@apply border-border outline-ring/50;
|
@apply border-border outline-ring/50;
|
||||||
|
|||||||
63
src/app/loading.tsx
Normal file
63
src/app/loading.tsx
Normal file
@ -0,0 +1,63 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Skeleton from "react-loading-skeleton";
|
||||||
|
import "react-loading-skeleton/dist/skeleton.css";
|
||||||
|
|
||||||
|
export default function Loading() {
|
||||||
|
return (
|
||||||
|
<div className="h-screen grid grid-cols-[280px_1fr]">
|
||||||
|
{/* Sidebar skeleton */}
|
||||||
|
<aside className="border-r h-full p-3 space-y-2">
|
||||||
|
<Skeleton height={24} width={120} />
|
||||||
|
<div className="space-y-2">
|
||||||
|
{Array.from({ length: 10 }).map((_, i) => (
|
||||||
|
<Skeleton key={i} height={28} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
{/* Main skeleton */}
|
||||||
|
<main className="h-full flex flex-col">
|
||||||
|
<header className="border-b p-3">
|
||||||
|
<Skeleton height={20} width={300} />
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section className="p-3 flex items-center gap-2 border-b">
|
||||||
|
<div className="flex items-center gap-2 w-full">
|
||||||
|
<Skeleton height={36} width={320} />
|
||||||
|
<Skeleton height={36} width={90} />
|
||||||
|
<Skeleton height={36} width={100} />
|
||||||
|
</div>
|
||||||
|
<Skeleton height={36} width={90} />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="p-3 flex-1 overflow-auto">
|
||||||
|
<div className="rounded-md border">
|
||||||
|
<div className="grid grid-cols-4 gap-2 px-3 py-2 border-b">
|
||||||
|
<Skeleton height={16} />
|
||||||
|
<Skeleton height={16} />
|
||||||
|
<Skeleton height={16} />
|
||||||
|
<Skeleton height={16} />
|
||||||
|
</div>
|
||||||
|
<div className="p-3 space-y-2">
|
||||||
|
{Array.from({ length: 12 }).map((_, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className="grid grid-cols-4 items-center gap-2 py-2 border-b last:border-b-0"
|
||||||
|
>
|
||||||
|
<Skeleton height={16} />
|
||||||
|
<Skeleton height={16} />
|
||||||
|
<Skeleton height={16} />
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<Skeleton height={28} width={28} circle />
|
||||||
|
<Skeleton height={28} width={28} circle />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -7,7 +7,7 @@ import { FileTable, type FileRow } from "@/components/files/file-table";
|
|||||||
import { UploadDialog } from "@/components/files/upload-dialog";
|
import { UploadDialog } from "@/components/files/upload-dialog";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
import { Checkbox } from "@/components/ui/checkbox";
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||||
import { MarkdownEditor } from "@/components/editor/markdown-editor";
|
import { MarkdownEditor } from "@/components/editor/markdown-editor";
|
||||||
@ -72,6 +72,7 @@ async function executeSearch(q: string, semantic: boolean, page: number, perPage
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
const [path, setPath] = React.useState<string | undefined>(undefined);
|
const [path, setPath] = React.useState<string | undefined>(undefined);
|
||||||
const [page, setPage] = React.useState(1);
|
const [page, setPage] = React.useState(1);
|
||||||
const [perPage] = React.useState(50);
|
const [perPage] = React.useState(50);
|
||||||
@ -152,6 +153,46 @@ export default function Home() {
|
|||||||
return res.json();
|
return res.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Optimistic delete mutation
|
||||||
|
const deleteMutation = useMutation({
|
||||||
|
mutationFn: async (file: FileRow) => {
|
||||||
|
return postJSON("/api/files/delete", { path: file.path });
|
||||||
|
},
|
||||||
|
onMutate: async (file: FileRow) => {
|
||||||
|
// Cancel any outgoing refetches (so they don't overwrite our optimistic update)
|
||||||
|
await queryClient.cancelQueries({ queryKey: ["files", path, page, perPage] });
|
||||||
|
// Snapshot previous value
|
||||||
|
const prev = queryClient.getQueryData(["files", path, page, perPage]) as
|
||||||
|
| { total: number; page: number; perPage: number; items: FileRow[] }
|
||||||
|
| undefined;
|
||||||
|
|
||||||
|
if (prev) {
|
||||||
|
// Optimistically remove the item
|
||||||
|
const nextItems = prev.items.filter((x) => x.id !== file.id);
|
||||||
|
queryClient.setQueryData(["files", path, page, perPage], {
|
||||||
|
...prev,
|
||||||
|
total: Math.max(0, (prev.total ?? nextItems.length) - 1),
|
||||||
|
items: nextItems,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return { prev };
|
||||||
|
},
|
||||||
|
onError: (error, _file, ctx) => {
|
||||||
|
if (ctx?.prev) {
|
||||||
|
queryClient.setQueryData(["files", path, page, perPage], ctx.prev);
|
||||||
|
}
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
toast.error(message);
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success("Deleted");
|
||||||
|
},
|
||||||
|
onSettled: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["files", path, page, perPage] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
async function handleRename(item: FileRow) {
|
async function handleRename(item: FileRow) {
|
||||||
const newName = window.prompt("New name (or full destination path):", item.name);
|
const newName = window.prompt("New name (or full destination path):", item.name);
|
||||||
if (!newName) return;
|
if (!newName) return;
|
||||||
@ -187,13 +228,7 @@ export default function Home() {
|
|||||||
async function handleDelete(item: FileRow) {
|
async function handleDelete(item: FileRow) {
|
||||||
const yes = window.confirm(`Delete "${item.name}"? This cannot be undone.`);
|
const yes = window.confirm(`Delete "${item.name}"? This cannot be undone.`);
|
||||||
if (!yes) return;
|
if (!yes) return;
|
||||||
try {
|
await deleteMutation.mutateAsync(item);
|
||||||
await postJSON("/api/files/delete", { path: item.path });
|
|
||||||
toast.success("Deleted");
|
|
||||||
if (!searching) filesQuery.refetch();
|
|
||||||
} catch (err) {
|
|
||||||
toast.error(err instanceof Error ? err.message : String(err));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleTags(item: FileRow) {
|
function handleTags(item: FileRow) {
|
||||||
|
|||||||
234
src/app/qdrant/page.tsx
Normal file
234
src/app/qdrant/page.tsx
Normal file
@ -0,0 +1,234 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||||
|
|
||||||
|
type CollectionRow = {
|
||||||
|
name: string;
|
||||||
|
vectors_count?: number;
|
||||||
|
points_count?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type CollectionsResponse = {
|
||||||
|
collections: CollectionRow[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type ScrollPoint = {
|
||||||
|
id: string | number;
|
||||||
|
payload?: Record<string, unknown>;
|
||||||
|
vector?: number[] | Record<string, number>;
|
||||||
|
};
|
||||||
|
|
||||||
|
type PointsResponse = {
|
||||||
|
points: ScrollPoint[];
|
||||||
|
next_page_offset?: string | number | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
async function fetchCollections() {
|
||||||
|
const res = await fetch("/api/qdrant/collections", { cache: "no-store" });
|
||||||
|
if (!res.ok) {
|
||||||
|
const payload = await res.json().catch(() => ({}));
|
||||||
|
throw new Error(payload?.message || `Failed to load collections (${res.status})`);
|
||||||
|
}
|
||||||
|
return (await res.json()) as CollectionsResponse;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchPoints(collection: string, limit: number, offset?: string | number | null, withVector = false) {
|
||||||
|
const url = new URL("/api/qdrant/points", window.location.origin);
|
||||||
|
url.searchParams.set("collection", collection);
|
||||||
|
url.searchParams.set("limit", String(limit));
|
||||||
|
if (offset != null) url.searchParams.set("offset", String(offset));
|
||||||
|
url.searchParams.set("withVector", String(withVector));
|
||||||
|
const res = await fetch(url.toString(), { cache: "no-store" });
|
||||||
|
if (!res.ok) {
|
||||||
|
const payload = await res.json().catch(() => ({}));
|
||||||
|
throw new Error(payload?.message || `Failed to load points (${res.status})`);
|
||||||
|
}
|
||||||
|
return (await res.json()) as PointsResponse;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function QdrantPage() {
|
||||||
|
const [selected, setSelected] = React.useState<string | null>(null);
|
||||||
|
const [limit, setLimit] = React.useState(100);
|
||||||
|
const [withVector, setWithVector] = React.useState(false);
|
||||||
|
const [offset, setOffset] = React.useState<string | number | null>(null);
|
||||||
|
|
||||||
|
const collectionsQuery = useQuery({
|
||||||
|
queryKey: ["qdrant-collections"],
|
||||||
|
queryFn: fetchCollections,
|
||||||
|
});
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
const names = collectionsQuery.data?.collections?.map((c) => c.name) ?? [];
|
||||||
|
if (!selected && names.length > 0) {
|
||||||
|
// Prefer fortura-db, then miguel_responses, then first
|
||||||
|
const preferred =
|
||||||
|
names.find((n) => n === "fortura-db") ||
|
||||||
|
names.find((n) => n === "miguel_responses") ||
|
||||||
|
names[0];
|
||||||
|
setSelected(preferred);
|
||||||
|
}
|
||||||
|
}, [collectionsQuery.data, selected]);
|
||||||
|
|
||||||
|
const pointsQuery = useQuery({
|
||||||
|
queryKey: ["qdrant-points", selected, limit, offset, withVector],
|
||||||
|
queryFn: () => fetchPoints(selected!, limit, offset, withVector),
|
||||||
|
enabled: !!selected,
|
||||||
|
});
|
||||||
|
|
||||||
|
function pretty(v: unknown) {
|
||||||
|
try {
|
||||||
|
return JSON.stringify(v, null, 2);
|
||||||
|
} catch {
|
||||||
|
return String(v);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-screen flex flex-col">
|
||||||
|
<header className="border-b p-3">
|
||||||
|
<h1 className="text-lg font-semibold">Qdrant Collections</h1>
|
||||||
|
</header>
|
||||||
|
<div className="flex-1 grid grid-cols-[320px_1fr]">
|
||||||
|
<aside className="border-r p-3">
|
||||||
|
<div className="text-sm font-medium mb-2">Collections</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
{collectionsQuery.isLoading && <div className="text-xs text-muted-foreground">Loading…</div>}
|
||||||
|
{collectionsQuery.error && (
|
||||||
|
<div className="text-xs text-destructive">
|
||||||
|
{(collectionsQuery.error as Error).message}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{collectionsQuery.data?.collections?.map((c) => (
|
||||||
|
<button
|
||||||
|
key={c.name}
|
||||||
|
className={`w-full text-left px-2 py-1 rounded hover:bg-accent ${
|
||||||
|
selected === c.name ? "bg-accent" : ""
|
||||||
|
}`}
|
||||||
|
onClick={() => {
|
||||||
|
setSelected(c.name);
|
||||||
|
setOffset(null);
|
||||||
|
}}
|
||||||
|
title={`${c.points_count ?? 0} pts`}
|
||||||
|
>
|
||||||
|
<div className="font-medium">{c.name}</div>
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
pts: {c.points_count ?? "—"} vec: {c.vectors_count ?? "—"}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
{collectionsQuery.data && collectionsQuery.data.collections.length === 0 && (
|
||||||
|
<div className="text-xs text-muted-foreground">No collections</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<main className="p-3 flex flex-col">
|
||||||
|
<div className="flex items-center gap-2 mb-3">
|
||||||
|
<div className="text-sm">Collection:</div>
|
||||||
|
<Input
|
||||||
|
value={selected ?? ""}
|
||||||
|
onChange={(e) => {
|
||||||
|
setSelected(e.target.value || null);
|
||||||
|
setOffset(null);
|
||||||
|
}}
|
||||||
|
placeholder="collection name"
|
||||||
|
className="max-w-xs"
|
||||||
|
/>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="text-sm">Limit</div>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={limit}
|
||||||
|
onChange={(e) => setLimit(Math.max(1, Math.min(1000, Number(e.target.value) || 100)))}
|
||||||
|
className="w-24"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant={withVector ? "default" : "outline"}
|
||||||
|
onClick={() => setWithVector((v) => !v)}
|
||||||
|
>
|
||||||
|
{withVector ? "Vectors: on" : "Vectors: off"}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
onClick={() => {
|
||||||
|
setOffset(null);
|
||||||
|
pointsQuery.refetch();
|
||||||
|
}}
|
||||||
|
disabled={!selected || pointsQuery.isFetching}
|
||||||
|
>
|
||||||
|
{pointsQuery.isFetching ? "Loading…" : "Refresh"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 grid grid-cols-2 gap-3">
|
||||||
|
{/* Points list */}
|
||||||
|
<div className="border rounded flex flex-col">
|
||||||
|
<div className="border-b px-3 py-2 text-sm font-medium">Points</div>
|
||||||
|
<ScrollArea className="flex-1">
|
||||||
|
<div className="p-3 space-y-3">
|
||||||
|
{pointsQuery.isLoading && <div className="text-xs text-muted-foreground">Loading…</div>}
|
||||||
|
{pointsQuery.error && (
|
||||||
|
<div className="text-xs text-destructive">
|
||||||
|
{(pointsQuery.error as Error).message}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{(pointsQuery.data?.points ?? []).map((p, idx) => (
|
||||||
|
<div key={`${p.id}-${idx}`} className="p-2 rounded border">
|
||||||
|
<div className="text-xs font-mono">id: {String(p.id)}</div>
|
||||||
|
<pre className="text-xs overflow-auto mt-1">
|
||||||
|
{pretty(p.payload)}
|
||||||
|
</pre>
|
||||||
|
{withVector && p.vector && (
|
||||||
|
<pre className="text-[10px] overflow-auto mt-1 text-muted-foreground">
|
||||||
|
{Array.isArray(p.vector) ? `[${p.vector.slice(0, 8).join(", ")} …]` : pretty(p.vector)}
|
||||||
|
</pre>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{(pointsQuery.data?.points ?? []).length === 0 && !pointsQuery.isLoading && !pointsQuery.error && (
|
||||||
|
<div className="text-xs text-muted-foreground">No points</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
<div className="border-t p-2 flex items-center justify-between">
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
Offset: {pointsQuery.data?.next_page_offset != null ? String(pointsQuery.data.next_page_offset) : "—"}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setOffset(null)}
|
||||||
|
disabled={!selected || pointsQuery.isFetching}
|
||||||
|
>
|
||||||
|
Reset
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setOffset(pointsQuery.data?.next_page_offset ?? null)}
|
||||||
|
disabled={!selected || pointsQuery.isFetching || !pointsQuery.data?.next_page_offset}
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Placeholder for scatter/UMAP visualization (follow-up) */}
|
||||||
|
<div className="border rounded p-3">
|
||||||
|
<div className="text-sm font-medium mb-2">Embedding Visualization</div>
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
UMAP + deck.gl scatter will render here after vectors are pulled. Toggle “Vectors: on” to include vectors in queries.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
42
src/lib/qdrant.ts
Normal file
42
src/lib/qdrant.ts
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
import { QdrantClient } from "@qdrant/qdrant-js";
|
||||||
|
import { env } from "@/lib/env";
|
||||||
|
|
||||||
|
let _client: QdrantClient | null = null;
|
||||||
|
|
||||||
|
export function getQdrantClient() {
|
||||||
|
if (_client) return _client;
|
||||||
|
if (!env.QDRANT_URL) {
|
||||||
|
throw new Error("QDRANT_URL not configured");
|
||||||
|
}
|
||||||
|
_client = new QdrantClient({
|
||||||
|
url: env.QDRANT_URL,
|
||||||
|
apiKey: env.QDRANT_API_KEY,
|
||||||
|
});
|
||||||
|
return _client;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listCollections() {
|
||||||
|
const client = getQdrantClient();
|
||||||
|
const res = await client.getCollections();
|
||||||
|
// Shape: { collections: [{ name, vectors_count, points_count, config: { params }, ... }] }
|
||||||
|
return res.collections ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ListPointsOpts = {
|
||||||
|
collection: string;
|
||||||
|
limit?: number;
|
||||||
|
offset?: string | number | null;
|
||||||
|
withVector?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function listPoints(opts: ListPointsOpts) {
|
||||||
|
const client = getQdrantClient();
|
||||||
|
const limit = opts.limit ?? 100;
|
||||||
|
const res = await client.scroll(opts.collection, {
|
||||||
|
limit,
|
||||||
|
with_payload: true,
|
||||||
|
with_vector: !!opts.withVector,
|
||||||
|
offset: opts.offset ?? undefined,
|
||||||
|
});
|
||||||
|
return res; // { points: [{ id, payload, vector }], next_page_offset? }
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user