Compare commits
No commits in common. "7df19c6696da77959030539a8ae729c5e33110cd" and "479e4614304d071bd134703cbc0c4ad477621726" have entirely different histories.
7df19c6696
...
479e461430
3064
package-lock.json
generated
3064
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -10,9 +10,7 @@
|
|||||||
"create:index": "tsx -r dotenv/config -r tsconfig-paths/register scripts/create-index.ts"
|
"create:index": "tsx -r dotenv/config -r tsconfig-paths/register scripts/create-index.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@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",
|
||||||
@ -21,22 +19,17 @@
|
|||||||
"@radix-ui/react-tooltip": "^1.2.8",
|
"@radix-ui/react-tooltip": "^1.2.8",
|
||||||
"@sentry/nextjs": "^10.11.0",
|
"@sentry/nextjs": "^10.11.0",
|
||||||
"@tanstack/react-query": "^5.87.4",
|
"@tanstack/react-query": "^5.87.4",
|
||||||
"@uiw/react-codemirror": "^4.25.1",
|
|
||||||
"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"
|
||||||
},
|
},
|
||||||
|
|||||||
@ -1,77 +0,0 @@
|
|||||||
import * as Sentry from "@sentry/nextjs";
|
|
||||||
import { NextResponse } from "next/server";
|
|
||||||
import { nextcloud } from "@/lib/webdav";
|
|
||||||
import { normalizePath } from "@/lib/paths";
|
|
||||||
|
|
||||||
export const runtime = "nodejs";
|
|
||||||
export const dynamic = "force-dynamic";
|
|
||||||
|
|
||||||
function json<T>(data: T, init?: { status?: number } & ResponseInit) {
|
|
||||||
return NextResponse.json(data, init);
|
|
||||||
}
|
|
||||||
|
|
||||||
// GET ?path=/abs/path -> { content, mimeType }
|
|
||||||
export async function GET(req: Request) {
|
|
||||||
try {
|
|
||||||
const { searchParams } = new URL(req.url);
|
|
||||||
const rawPath = searchParams.get("path");
|
|
||||||
if (!rawPath) {
|
|
||||||
return json({ error: "path is required" }, { status: 400 });
|
|
||||||
}
|
|
||||||
const path = normalizePath(rawPath);
|
|
||||||
|
|
||||||
const result = await Sentry.startSpan(
|
|
||||||
{ op: "function", name: "api.files.content.get" },
|
|
||||||
async (span) => {
|
|
||||||
span.setAttribute("path", path);
|
|
||||||
const res = await nextcloud.readText(path);
|
|
||||||
return res;
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
return json(result);
|
|
||||||
} catch (error: unknown) {
|
|
||||||
Sentry.captureException(error);
|
|
||||||
const message = error instanceof Error ? error.message : String(error);
|
|
||||||
const status = /404/.test(message) ? 404 : 500;
|
|
||||||
return json({ error: "Failed to read file content", message }, { status });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// PUT { path, content, mimeType? } -> { ok, etag? }
|
|
||||||
export async function PUT(req: Request) {
|
|
||||||
try {
|
|
||||||
const body = (await req.json().catch(() => ({}))) as {
|
|
||||||
path?: string;
|
|
||||||
content?: string;
|
|
||||||
mimeType?: string;
|
|
||||||
};
|
|
||||||
if (!body?.path || typeof body.content !== "string") {
|
|
||||||
return json({ error: "path and content are required" }, { status: 400 });
|
|
||||||
}
|
|
||||||
const path = normalizePath(body.path);
|
|
||||||
const mime = body.mimeType || "text/markdown";
|
|
||||||
|
|
||||||
const result = await Sentry.startSpan(
|
|
||||||
{ op: "function", name: "api.files.content.put" },
|
|
||||||
async (span) => {
|
|
||||||
span.setAttribute("path", path);
|
|
||||||
span.setAttribute("content.length", body.content!.length);
|
|
||||||
span.setAttribute("mimeType", mime);
|
|
||||||
const res = await nextcloud.writeText(path, body.content!, mime);
|
|
||||||
return res;
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
return json(result);
|
|
||||||
} catch (error: unknown) {
|
|
||||||
Sentry.captureException(error);
|
|
||||||
const message = error instanceof Error ? error.message : String(error);
|
|
||||||
const status = /409|conflict/i.test(message)
|
|
||||||
? 409
|
|
||||||
: /423|locked/i.test(message)
|
|
||||||
? 423
|
|
||||||
: 500;
|
|
||||||
return json({ error: "Failed to write file content", message }, { status });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,48 +0,0 @@
|
|||||||
import * as Sentry from "@sentry/nextjs";
|
|
||||||
import { NextResponse } from "next/server";
|
|
||||||
import { nextcloud } from "@/lib/webdav";
|
|
||||||
import { normalizePath } from "@/lib/paths";
|
|
||||||
|
|
||||||
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 POST(req: Request) {
|
|
||||||
try {
|
|
||||||
const body = (await req.json().catch(() => ({}))) as {
|
|
||||||
from?: string;
|
|
||||||
to?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!body?.from || !body?.to) {
|
|
||||||
return json({ error: "from and to are required" }, { status: 400 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const from = normalizePath(body.from);
|
|
||||||
const to = normalizePath(body.to);
|
|
||||||
|
|
||||||
const result = await Sentry.startSpan(
|
|
||||||
{ op: "function", name: "api.files.copy" },
|
|
||||||
async (span) => {
|
|
||||||
span.setAttribute("path_from", from);
|
|
||||||
span.setAttribute("path_to", to);
|
|
||||||
const res = await nextcloud.copyFile(from, to);
|
|
||||||
return res;
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
return json(result);
|
|
||||||
} catch (error: unknown) {
|
|
||||||
Sentry.captureException(error);
|
|
||||||
const message = error instanceof Error ? error.message : String(error);
|
|
||||||
const status = /409|conflict/i.test(message)
|
|
||||||
? 409
|
|
||||||
: /423|locked/i.test(message)
|
|
||||||
? 423
|
|
||||||
: 500;
|
|
||||||
return json({ error: "Failed to copy file", message }, { status });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,45 +0,0 @@
|
|||||||
import * as Sentry from "@sentry/nextjs";
|
|
||||||
import { NextResponse } from "next/server";
|
|
||||||
import { nextcloud } from "@/lib/webdav";
|
|
||||||
import { normalizePath } from "@/lib/paths";
|
|
||||||
|
|
||||||
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 POST(req: Request) {
|
|
||||||
try {
|
|
||||||
const body = (await req.json().catch(() => ({}))) as {
|
|
||||||
path?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!body?.path) {
|
|
||||||
return json({ error: "path is required" }, { status: 400 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const path = normalizePath(body.path);
|
|
||||||
|
|
||||||
const result = await Sentry.startSpan(
|
|
||||||
{ op: "function", name: "api.files.delete" },
|
|
||||||
async (span) => {
|
|
||||||
span.setAttribute("path", path);
|
|
||||||
const res = await nextcloud.deletePath(path);
|
|
||||||
return res;
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
return json(result);
|
|
||||||
} catch (error: unknown) {
|
|
||||||
Sentry.captureException(error);
|
|
||||||
const message = error instanceof Error ? error.message : String(error);
|
|
||||||
const status = /409|conflict/i.test(message)
|
|
||||||
? 409
|
|
||||||
: /423|locked/i.test(message)
|
|
||||||
? 423
|
|
||||||
: 500;
|
|
||||||
return json({ error: "Failed to delete path", message }, { status });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,49 +0,0 @@
|
|||||||
import * as Sentry from "@sentry/nextjs";
|
|
||||||
import { NextResponse } from "next/server";
|
|
||||||
import { nextcloud } from "@/lib/webdav";
|
|
||||||
import { normalizePath } from "@/lib/paths";
|
|
||||||
|
|
||||||
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 POST(req: Request) {
|
|
||||||
try {
|
|
||||||
const body = (await req.json().catch(() => ({}))) as {
|
|
||||||
from?: string;
|
|
||||||
to?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!body?.from || !body?.to) {
|
|
||||||
return json({ error: "from and to are required" }, { status: 400 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const from = normalizePath(body.from);
|
|
||||||
const to = normalizePath(body.to);
|
|
||||||
|
|
||||||
const result = await Sentry.startSpan(
|
|
||||||
{ op: "function", name: "api.files.rename" },
|
|
||||||
async (span) => {
|
|
||||||
span.setAttribute("path_from", from);
|
|
||||||
span.setAttribute("path_to", to);
|
|
||||||
const res = await nextcloud.moveFile(from, to);
|
|
||||||
return res;
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
return json(result);
|
|
||||||
} catch (error: unknown) {
|
|
||||||
Sentry.captureException(error);
|
|
||||||
const message = error instanceof Error ? error.message : String(error);
|
|
||||||
// Map some common WebDAV conflict/locked responses if available
|
|
||||||
const status = /409|conflict/i.test(message)
|
|
||||||
? 409
|
|
||||||
: /423|locked/i.test(message)
|
|
||||||
? 423
|
|
||||||
: 500;
|
|
||||||
return json({ error: "Failed to rename file", message }, { status });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,65 +0,0 @@
|
|||||||
import * as Sentry from "@sentry/nextjs";
|
|
||||||
import { NextResponse } from "next/server";
|
|
||||||
import { getEsClient } from "@/lib/elasticsearch";
|
|
||||||
|
|
||||||
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/files/tags/history?path=/abs/path&size=50
|
|
||||||
export async function GET(req: Request) {
|
|
||||||
try {
|
|
||||||
const { searchParams } = new URL(req.url);
|
|
||||||
const path = searchParams.get("path");
|
|
||||||
const sizeParam = searchParams.get("size");
|
|
||||||
const size = Math.min(Math.max(Number(sizeParam || 50) || 50, 1), 500);
|
|
||||||
|
|
||||||
if (!path) {
|
|
||||||
return json({ error: "path is required" }, { status: 400 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await Sentry.startSpan(
|
|
||||||
{ op: "db.query", name: "ES list tag history" },
|
|
||||||
async (span) => {
|
|
||||||
span.setAttribute("path", path);
|
|
||||||
span.setAttribute("size", size);
|
|
||||||
|
|
||||||
const client = getEsClient();
|
|
||||||
const res = await client.search({
|
|
||||||
index: "files_events",
|
|
||||||
size,
|
|
||||||
sort: [{ at: { order: "desc" } }],
|
|
||||||
query: {
|
|
||||||
bool: {
|
|
||||||
must: [
|
|
||||||
{ term: { path } },
|
|
||||||
{ term: { action: "tags.update" } },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const hits = (res.hits.hits || []).map((h) => ({
|
|
||||||
id: (h._id as string) ?? undefined,
|
|
||||||
...(h._source as Record<string, unknown>),
|
|
||||||
}));
|
|
||||||
|
|
||||||
const total =
|
|
||||||
typeof res.hits.total === "object" && res.hits.total !== null
|
|
||||||
? (res.hits.total as { value: number; relation?: string }).value
|
|
||||||
: (res.hits.total as number | undefined) ?? hits.length;
|
|
||||||
|
|
||||||
return { total, items: hits };
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
return json(result);
|
|
||||||
} catch (error: unknown) {
|
|
||||||
Sentry.captureException(error);
|
|
||||||
const message = error instanceof Error ? error.message : String(error);
|
|
||||||
return json({ error: "Failed to fetch tag history", message }, { status: 500 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,78 +0,0 @@
|
|||||||
import * as Sentry from "@sentry/nextjs";
|
|
||||||
import { NextResponse } from "next/server";
|
|
||||||
import { getEsClient } from "@/lib/elasticsearch";
|
|
||||||
import { env } from "@/lib/env";
|
|
||||||
import { pathToId } from "@/lib/paths";
|
|
||||||
import type { TagHistoryEvent } from "@/types/files";
|
|
||||||
|
|
||||||
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 POST(req: Request) {
|
|
||||||
try {
|
|
||||||
const body = (await req.json().catch(() => ({}))) as {
|
|
||||||
path?: string;
|
|
||||||
tags?: string[];
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!body?.path || !Array.isArray(body.tags)) {
|
|
||||||
return json({ error: "path and tags[] are required" }, { status: 400 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const path = body.path;
|
|
||||||
const tags = body.tags;
|
|
||||||
const id = pathToId(path);
|
|
||||||
const index = env.ELASTICSEARCH_ALIAS || env.ELASTICSEARCH_INDEX;
|
|
||||||
const eventsIndex = "files_events";
|
|
||||||
|
|
||||||
await Sentry.startSpan(
|
|
||||||
{ op: "db.query", name: "ES updateTags + appendTagHistory" },
|
|
||||||
async (span) => {
|
|
||||||
span.setAttribute("doc.id", id);
|
|
||||||
span.setAttribute("tags.count", tags.length);
|
|
||||||
|
|
||||||
const client = getEsClient();
|
|
||||||
|
|
||||||
// Update tags on the main document (upsert to be resilient)
|
|
||||||
await client.update({
|
|
||||||
index,
|
|
||||||
id,
|
|
||||||
doc: { tags },
|
|
||||||
doc_as_upsert: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Append a tag history event (append-only). We don't block on failures here.
|
|
||||||
const event: TagHistoryEvent = {
|
|
||||||
id: (globalThis.crypto?.randomUUID?.() ??
|
|
||||||
Math.random().toString(36).slice(2) + Date.now().toString(36)) as string,
|
|
||||||
path,
|
|
||||||
action: "tags.update",
|
|
||||||
to: tags,
|
|
||||||
at: new Date().toISOString(),
|
|
||||||
};
|
|
||||||
|
|
||||||
try {
|
|
||||||
await client.index({
|
|
||||||
index: eventsIndex,
|
|
||||||
id: event.id,
|
|
||||||
document: event,
|
|
||||||
refresh: "wait_for",
|
|
||||||
});
|
|
||||||
} catch (e) {
|
|
||||||
// Non-fatal; emit to Sentry but continue
|
|
||||||
Sentry.captureException(e);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
return json({ ok: true, tags });
|
|
||||||
} catch (error: unknown) {
|
|
||||||
Sentry.captureException(error);
|
|
||||||
const message = error instanceof Error ? error.message : String(error);
|
|
||||||
return json({ error: "Failed to update tags", message }, { status: 500 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,39 +0,0 @@
|
|||||||
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 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,50 +0,0 @@
|
|||||||
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,76 +44,74 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
--radius: 0.65rem;
|
--radius: 0.625rem;
|
||||||
--background: oklch(1 0 0);
|
--background: oklch(1 0 0);
|
||||||
--foreground: oklch(0.145 0 0);
|
--foreground: oklch(0.129 0.042 264.695);
|
||||||
--card: oklch(1 0 0);
|
--card: oklch(1 0 0);
|
||||||
--card-foreground: oklch(0.145 0 0);
|
--card-foreground: oklch(0.129 0.042 264.695);
|
||||||
--popover: oklch(1 0 0);
|
--popover: oklch(1 0 0);
|
||||||
--popover-foreground: oklch(0.145 0 0);
|
--popover-foreground: oklch(0.129 0.042 264.695);
|
||||||
--primary: oklch(0.205 0 0);
|
--primary: oklch(0.208 0.042 265.755);
|
||||||
--primary-foreground: oklch(0.985 0 0);
|
--primary-foreground: oklch(0.984 0.003 247.858);
|
||||||
--secondary: oklch(0.97 0 0);
|
--secondary: oklch(0.968 0.007 247.896);
|
||||||
--secondary-foreground: oklch(0.205 0 0);
|
--secondary-foreground: oklch(0.208 0.042 265.755);
|
||||||
--muted: oklch(0.97 0 0);
|
--muted: oklch(0.968 0.007 247.896);
|
||||||
--muted-foreground: oklch(0.556 0 0);
|
--muted-foreground: oklch(0.554 0.046 257.417);
|
||||||
--accent: oklch(0.97 0 0);
|
--accent: oklch(0.968 0.007 247.896);
|
||||||
--accent-foreground: oklch(0.205 0 0);
|
--accent-foreground: oklch(0.208 0.042 265.755);
|
||||||
--destructive: oklch(0.577 0.245 27.325);
|
--destructive: oklch(0.577 0.245 27.325);
|
||||||
--border: oklch(0.922 0 0);
|
--border: oklch(0.929 0.013 255.508);
|
||||||
--input: oklch(0.922 0 0);
|
--input: oklch(0.929 0.013 255.508);
|
||||||
--ring: oklch(0.708 0 0);
|
--ring: oklch(0.704 0.04 256.788);
|
||||||
--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);
|
||||||
--radius: 0.625rem;
|
--sidebar: oklch(0.984 0.003 247.858);
|
||||||
--sidebar: oklch(0.985 0 0);
|
--sidebar-foreground: oklch(0.129 0.042 264.695);
|
||||||
--sidebar-foreground: oklch(0.145 0 0);
|
--sidebar-primary: oklch(0.208 0.042 265.755);
|
||||||
--sidebar-primary: oklch(0.205 0 0);
|
--sidebar-primary-foreground: oklch(0.984 0.003 247.858);
|
||||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
--sidebar-accent: oklch(0.968 0.007 247.896);
|
||||||
--sidebar-accent: oklch(0.97 0 0);
|
--sidebar-accent-foreground: oklch(0.208 0.042 265.755);
|
||||||
--sidebar-accent-foreground: oklch(0.205 0 0);
|
--sidebar-border: oklch(0.929 0.013 255.508);
|
||||||
--sidebar-border: oklch(0.922 0 0);
|
--sidebar-ring: oklch(0.704 0.04 256.788);
|
||||||
--sidebar-ring: oklch(0.708 0 0);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark {
|
.dark {
|
||||||
--background: oklch(0.145 0 0);
|
--background: oklch(0.129 0.042 264.695);
|
||||||
--foreground: oklch(0.985 0 0);
|
--foreground: oklch(0.984 0.003 247.858);
|
||||||
--card: oklch(0.205 0 0);
|
--card: oklch(0.208 0.042 265.755);
|
||||||
--card-foreground: oklch(0.985 0 0);
|
--card-foreground: oklch(0.984 0.003 247.858);
|
||||||
--popover: oklch(0.205 0 0);
|
--popover: oklch(0.208 0.042 265.755);
|
||||||
--popover-foreground: oklch(0.985 0 0);
|
--popover-foreground: oklch(0.984 0.003 247.858);
|
||||||
--primary: oklch(0.922 0 0);
|
--primary: oklch(0.929 0.013 255.508);
|
||||||
--primary-foreground: oklch(0.205 0 0);
|
--primary-foreground: oklch(0.208 0.042 265.755);
|
||||||
--secondary: oklch(0.269 0 0);
|
--secondary: oklch(0.279 0.041 260.031);
|
||||||
--secondary-foreground: oklch(0.985 0 0);
|
--secondary-foreground: oklch(0.984 0.003 247.858);
|
||||||
--muted: oklch(0.269 0 0);
|
--muted: oklch(0.279 0.041 260.031);
|
||||||
--muted-foreground: oklch(0.708 0 0);
|
--muted-foreground: oklch(0.704 0.04 256.788);
|
||||||
--accent: oklch(0.269 0 0);
|
--accent: oklch(0.279 0.041 260.031);
|
||||||
--accent-foreground: oklch(0.985 0 0);
|
--accent-foreground: oklch(0.984 0.003 247.858);
|
||||||
--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.556 0 0);
|
--ring: oklch(0.551 0.027 264.364);
|
||||||
--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.205 0 0);
|
--sidebar: oklch(0.208 0.042 265.755);
|
||||||
--sidebar-foreground: oklch(0.985 0 0);
|
--sidebar-foreground: oklch(0.984 0.003 247.858);
|
||||||
--sidebar-primary: oklch(0.488 0.243 264.376);
|
--sidebar-primary: oklch(0.488 0.243 264.376);
|
||||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
--sidebar-primary-foreground: oklch(0.984 0.003 247.858);
|
||||||
--sidebar-accent: oklch(0.269 0 0);
|
--sidebar-accent: oklch(0.279 0.041 260.031);
|
||||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
--sidebar-accent-foreground: oklch(0.984 0.003 247.858);
|
||||||
--sidebar-border: oklch(1 0 0 / 10%);
|
--sidebar-border: oklch(1 0 0 / 10%);
|
||||||
--sidebar-ring: oklch(0.556 0 0);
|
--sidebar-ring: oklch(0.551 0.027 264.364);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@layer base {
|
@layer base {
|
||||||
* {
|
* {
|
||||||
@apply border-border outline-ring/50;
|
@apply border-border outline-ring/50;
|
||||||
|
|||||||
@ -1,63 +0,0 @@
|
|||||||
"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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
147
src/app/page.tsx
147
src/app/page.tsx
@ -7,12 +7,8 @@ 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, useMutation, useQueryClient } from "@tanstack/react-query";
|
import { useQuery } 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 { MarkdownEditor } from "@/components/editor/markdown-editor";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
import { TagsDialog } from "@/components/files/tags-dialog";
|
|
||||||
|
|
||||||
type FilesListResponse = {
|
type FilesListResponse = {
|
||||||
total: number;
|
total: number;
|
||||||
@ -72,7 +68,6 @@ 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);
|
||||||
@ -81,12 +76,6 @@ export default function Home() {
|
|||||||
const [semantic, setSemantic] = React.useState(false);
|
const [semantic, setSemantic] = React.useState(false);
|
||||||
const searching = q.trim().length > 0;
|
const searching = q.trim().length > 0;
|
||||||
|
|
||||||
// Editor state
|
|
||||||
const [editPath, setEditPath] = React.useState<string | null>(null);
|
|
||||||
|
|
||||||
// Tags dialog state
|
|
||||||
const [tagsPath, setTagsPath] = React.useState<string | null>(null);
|
|
||||||
|
|
||||||
const filesQuery = useQuery({
|
const filesQuery = useQuery({
|
||||||
queryKey: ["files", path, page, perPage],
|
queryKey: ["files", path, page, perPage],
|
||||||
queryFn: () => fetchFiles(path, page, perPage),
|
queryFn: () => fetchFiles(path, page, perPage),
|
||||||
@ -136,105 +125,6 @@ export default function Home() {
|
|||||||
handleDownload(item);
|
handleDownload(item);
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleEdit(item: FileRow) {
|
|
||||||
setEditPath(item.path);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function postJSON(url: string, body: unknown) {
|
|
||||||
const res = await fetch(url, {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "content-type": "application/json" },
|
|
||||||
body: JSON.stringify(body),
|
|
||||||
});
|
|
||||||
if (!res.ok) {
|
|
||||||
const payload = await res.json().catch(() => ({}));
|
|
||||||
throw new Error(payload?.message || `Request failed (${res.status})`);
|
|
||||||
}
|
|
||||||
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) {
|
|
||||||
const newName = window.prompt("New name (or full destination path):", item.name);
|
|
||||||
if (!newName) return;
|
|
||||||
const to =
|
|
||||||
newName.startsWith("/")
|
|
||||||
? newName
|
|
||||||
: `${item.parentPath ?? ""}/${newName}`.replace(/\/+/g, "/");
|
|
||||||
try {
|
|
||||||
await postJSON("/api/files/rename", { from: item.path, to });
|
|
||||||
toast.success("Renamed");
|
|
||||||
if (!searching) filesQuery.refetch();
|
|
||||||
} catch (err) {
|
|
||||||
toast.error(err instanceof Error ? err.message : String(err));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleCopy(item: FileRow) {
|
|
||||||
const newPath = window.prompt("Copy to path (absolute or name in same folder):", `${item.parentPath ?? ""}/${item.name}`);
|
|
||||||
if (!newPath) return;
|
|
||||||
const to =
|
|
||||||
newPath.startsWith("/")
|
|
||||||
? newPath
|
|
||||||
: `${item.parentPath ?? ""}/${newPath}`.replace(/\/+/g, "/");
|
|
||||||
try {
|
|
||||||
await postJSON("/api/files/copy", { from: item.path, to });
|
|
||||||
toast.success("Copied");
|
|
||||||
if (!searching) filesQuery.refetch();
|
|
||||||
} catch (err) {
|
|
||||||
toast.error(err instanceof Error ? err.message : String(err));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleDelete(item: FileRow) {
|
|
||||||
const yes = window.confirm(`Delete "${item.name}"? This cannot be undone.`);
|
|
||||||
if (!yes) return;
|
|
||||||
await deleteMutation.mutateAsync(item);
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleTags(item: FileRow) {
|
|
||||||
setTagsPath(item.path);
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleUploaded() {
|
function handleUploaded() {
|
||||||
if (!searching) {
|
if (!searching) {
|
||||||
filesQuery.refetch();
|
filesQuery.refetch();
|
||||||
@ -291,42 +181,9 @@ export default function Home() {
|
|||||||
: `Found ${searchQuery.data?.total ?? 0} in ${searchQuery.data?.tookMs ?? 0}ms`}
|
: `Found ${searchQuery.data?.total ?? 0} in ${searchQuery.data?.tookMs ?? 0}ms`}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
<FileTable
|
<FileTable items={files} onOpen={handleOpen} onDownload={handleDownload} />
|
||||||
items={files}
|
|
||||||
onOpen={handleOpen}
|
|
||||||
onDownload={handleDownload}
|
|
||||||
onEdit={handleEdit}
|
|
||||||
onRename={handleRename}
|
|
||||||
onCopy={handleCopy}
|
|
||||||
onDelete={handleDelete}
|
|
||||||
onTags={handleTags}
|
|
||||||
/>
|
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
{/* Editor dialog */}
|
|
||||||
<Dialog open={!!editPath} onOpenChange={(v) => !v && setEditPath(null)}>
|
|
||||||
<DialogContent className="max-w-4xl h-[80vh]">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>Markdown Editor</DialogTitle>
|
|
||||||
</DialogHeader>
|
|
||||||
<div className="h-[calc(80vh-60px)]">
|
|
||||||
{editPath ? <MarkdownEditor path={editPath} /> : null}
|
|
||||||
</div>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
|
|
||||||
{/* Tags dialog */}
|
|
||||||
<TagsDialog
|
|
||||||
path={tagsPath ?? ""}
|
|
||||||
open={!!tagsPath}
|
|
||||||
onOpenChange={(v) => !v && setTagsPath(null)}
|
|
||||||
initialTags={[]}
|
|
||||||
onSaved={() => {
|
|
||||||
// Optionally refresh file list to reflect updated tags when surface supports it
|
|
||||||
if (!searching) filesQuery.refetch();
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,234 +0,0 @@
|
|||||||
"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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,103 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import * as React from "react";
|
|
||||||
import { useQuery, useMutation } from "@tanstack/react-query";
|
|
||||||
import CodeMirror from "@uiw/react-codemirror";
|
|
||||||
import { markdown } from "@codemirror/lang-markdown";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
path: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
async function fetchContent(path: string) {
|
|
||||||
const url = new URL("/api/files/content", window.location.origin);
|
|
||||||
url.searchParams.set("path", path);
|
|
||||||
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 content (${res.status})`);
|
|
||||||
}
|
|
||||||
return (await res.json()) as { ok: boolean; content?: string; mimeType?: string };
|
|
||||||
}
|
|
||||||
|
|
||||||
async function saveContent(path: string, content: string, mimeType?: string) {
|
|
||||||
const res = await fetch("/api/files/content", {
|
|
||||||
method: "PUT",
|
|
||||||
headers: { "content-type": "application/json" },
|
|
||||||
body: JSON.stringify({ path, content, mimeType: mimeType || "text/markdown" }),
|
|
||||||
});
|
|
||||||
if (!res.ok) {
|
|
||||||
const payload = await res.json().catch(() => ({}));
|
|
||||||
throw new Error(payload?.message || `Failed to save content (${res.status})`);
|
|
||||||
}
|
|
||||||
return (await res.json()) as { ok: boolean; etag?: string };
|
|
||||||
}
|
|
||||||
|
|
||||||
export function MarkdownEditor({ path }: Props) {
|
|
||||||
const [value, setValue] = React.useState("");
|
|
||||||
const [dirty, setDirty] = React.useState(false);
|
|
||||||
|
|
||||||
const query = useQuery({
|
|
||||||
queryKey: ["file-content", path],
|
|
||||||
queryFn: () => fetchContent(path),
|
|
||||||
refetchOnWindowFocus: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
if (query.data?.content != null) {
|
|
||||||
setValue(query.data.content);
|
|
||||||
setDirty(false);
|
|
||||||
}
|
|
||||||
}, [query.data?.content]);
|
|
||||||
|
|
||||||
const mutation = useMutation({
|
|
||||||
mutationFn: (payload: { content: string; mimeType?: string }) =>
|
|
||||||
saveContent(path, payload.content, payload.mimeType),
|
|
||||||
onSuccess: () => {
|
|
||||||
toast.success("Saved");
|
|
||||||
setDirty(false);
|
|
||||||
},
|
|
||||||
onError: (err: unknown) => {
|
|
||||||
toast.error(err instanceof Error ? err.message : String(err));
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col h-full">
|
|
||||||
<div className="flex items-center justify-between gap-2 border-b px-3 py-2">
|
|
||||||
<div className="text-sm text-muted-foreground truncate">Editing: <code>{path}</code></div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Button
|
|
||||||
variant="secondary"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => query.refetch()}
|
|
||||||
disabled={query.isFetching}
|
|
||||||
>
|
|
||||||
{query.isFetching ? "Refreshing..." : "Refresh"}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
onClick={() => mutation.mutate({ content: value, mimeType: query.data?.mimeType || "text/markdown" })}
|
|
||||||
disabled={mutation.isPending || !dirty}
|
|
||||||
>
|
|
||||||
{mutation.isPending ? "Saving..." : "Save"}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex-1 overflow-auto">
|
|
||||||
<CodeMirror
|
|
||||||
value={value}
|
|
||||||
height="calc(100vh - 140px)"
|
|
||||||
extensions={[markdown()]}
|
|
||||||
onChange={(v) => {
|
|
||||||
setValue(v);
|
|
||||||
setDirty(true);
|
|
||||||
}}
|
|
||||||
theme="dark"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,67 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import * as React from "react";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import {
|
|
||||||
DropdownMenu,
|
|
||||||
DropdownMenuTrigger,
|
|
||||||
DropdownMenuContent,
|
|
||||||
DropdownMenuItem,
|
|
||||||
DropdownMenuSeparator,
|
|
||||||
} from "@/components/ui/dropdown-menu";
|
|
||||||
import { MoreHorizontal, Pencil, Copy, Trash2, Download, MoveRight, Tag } from "lucide-react";
|
|
||||||
|
|
||||||
export type FileRowActionHandlers = {
|
|
||||||
onEdit?: () => void;
|
|
||||||
onDownload?: () => void;
|
|
||||||
onRename?: () => void;
|
|
||||||
onCopy?: () => void;
|
|
||||||
onDelete?: () => void;
|
|
||||||
onTags?: () => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function FileRowActions({
|
|
||||||
onEdit,
|
|
||||||
onDownload,
|
|
||||||
onRename,
|
|
||||||
onCopy,
|
|
||||||
onDelete,
|
|
||||||
onTags,
|
|
||||||
}: FileRowActionHandlers) {
|
|
||||||
return (
|
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenuTrigger asChild>
|
|
||||||
<Button variant="ghost" size="sm" aria-label="Actions">
|
|
||||||
<MoreHorizontal className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent align="end" className="w-40">
|
|
||||||
<DropdownMenuItem onClick={onEdit}>
|
|
||||||
<Pencil className="mr-2 h-4 w-4" />
|
|
||||||
Edit
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem onClick={onDownload}>
|
|
||||||
<Download className="mr-2 h-4 w-4" />
|
|
||||||
Download
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuSeparator />
|
|
||||||
<DropdownMenuItem onClick={onRename}>
|
|
||||||
<MoveRight className="mr-2 h-4 w-4" />
|
|
||||||
Rename / Move
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem onClick={onTags}>
|
|
||||||
<Tag className="mr-2 h-4 w-4" />
|
|
||||||
Tags
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem onClick={onCopy}>
|
|
||||||
<Copy className="mr-2 h-4 w-4" />
|
|
||||||
Copy
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem onClick={onDelete} className="text-destructive">
|
|
||||||
<Trash2 className="mr-2 h-4 w-4" />
|
|
||||||
Delete
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -4,7 +4,6 @@ import * as React from "react";
|
|||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Download } from "lucide-react";
|
import { Download } from "lucide-react";
|
||||||
import { FileRowActions } from "@/components/files/file-row-actions";
|
|
||||||
|
|
||||||
export interface FileRow {
|
export interface FileRow {
|
||||||
id: string;
|
id: string;
|
||||||
@ -29,20 +28,10 @@ export function FileTable({
|
|||||||
items,
|
items,
|
||||||
onOpen,
|
onOpen,
|
||||||
onDownload,
|
onDownload,
|
||||||
onEdit,
|
|
||||||
onRename,
|
|
||||||
onCopy,
|
|
||||||
onDelete,
|
|
||||||
onTags,
|
|
||||||
}: {
|
}: {
|
||||||
items: FileRow[];
|
items: FileRow[];
|
||||||
onOpen?: (item: FileRow) => void;
|
onOpen?: (item: FileRow) => void;
|
||||||
onDownload?: (item: FileRow) => void;
|
onDownload?: (item: FileRow) => void;
|
||||||
onEdit?: (item: FileRow) => void;
|
|
||||||
onRename?: (item: FileRow) => void;
|
|
||||||
onCopy?: (item: FileRow) => void;
|
|
||||||
onDelete?: (item: FileRow) => void;
|
|
||||||
onTags?: (item: FileRow) => void;
|
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="rounded-md border">
|
<div className="rounded-md border">
|
||||||
@ -70,24 +59,14 @@ export function FileTable({
|
|||||||
<TableCell className="hidden sm:table-cell">{it.mimeType}</TableCell>
|
<TableCell className="hidden sm:table-cell">{it.mimeType}</TableCell>
|
||||||
<TableCell className="hidden sm:table-cell text-right">{formatBytes(it.sizeBytes)}</TableCell>
|
<TableCell className="hidden sm:table-cell text-right">{formatBytes(it.sizeBytes)}</TableCell>
|
||||||
<TableCell className="text-right">
|
<TableCell className="text-right">
|
||||||
<div className="flex justify-end gap-1">
|
<Button
|
||||||
<Button
|
variant="ghost"
|
||||||
variant="ghost"
|
size="sm"
|
||||||
size="sm"
|
onClick={() => onDownload?.(it)}
|
||||||
onClick={() => onDownload?.(it)}
|
title="Download"
|
||||||
title="Download"
|
>
|
||||||
>
|
<Download className="h-4 w-4" />
|
||||||
<Download className="h-4 w-4" />
|
</Button>
|
||||||
</Button>
|
|
||||||
<FileRowActions
|
|
||||||
onEdit={() => onEdit?.(it)}
|
|
||||||
onDownload={() => onDownload?.(it)}
|
|
||||||
onRename={() => onRename?.(it)}
|
|
||||||
onCopy={() => onCopy?.(it)}
|
|
||||||
onDelete={() => onDelete?.(it)}
|
|
||||||
onTags={() => onTags?.(it)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@ -1,154 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import * as React from "react";
|
|
||||||
import { useQuery, useMutation } from "@tanstack/react-query";
|
|
||||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
import { Badge } from "@/components/ui/badge";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
|
|
||||||
export function parseTags(input: string): string[] {
|
|
||||||
return input
|
|
||||||
.split(",")
|
|
||||||
.map((s) => s.trim())
|
|
||||||
.filter((s) => s.length > 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function fetchHistory(path: string, size = 50) {
|
|
||||||
const url = new URL("/api/files/tags/history", window.location.origin);
|
|
||||||
url.searchParams.set("path", path);
|
|
||||||
url.searchParams.set("size", String(size));
|
|
||||||
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 tag history (${res.status})`);
|
|
||||||
}
|
|
||||||
return (await res.json()) as { total: number; items: Array<{ id?: string; to?: string[]; at?: string }> };
|
|
||||||
}
|
|
||||||
|
|
||||||
async function saveTags(path: string, tags: string[]) {
|
|
||||||
const res = await fetch("/api/files/tags", {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "content-type": "application/json" },
|
|
||||||
body: JSON.stringify({ path, tags }),
|
|
||||||
});
|
|
||||||
if (!res.ok) {
|
|
||||||
const payload = await res.json().catch(() => ({}));
|
|
||||||
throw new Error(payload?.message || `Failed to save tags (${res.status})`);
|
|
||||||
}
|
|
||||||
return (await res.json()) as { ok: boolean; tags: string[] };
|
|
||||||
}
|
|
||||||
|
|
||||||
export function TagsDialog({
|
|
||||||
path,
|
|
||||||
open,
|
|
||||||
onOpenChange,
|
|
||||||
initialTags,
|
|
||||||
onSaved,
|
|
||||||
}: {
|
|
||||||
path: string;
|
|
||||||
open: boolean;
|
|
||||||
onOpenChange: (v: boolean) => void;
|
|
||||||
initialTags?: string[];
|
|
||||||
onSaved?: (tags: string[]) => void;
|
|
||||||
}) {
|
|
||||||
const [value, setValue] = React.useState((initialTags ?? []).join(", "));
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
setValue((initialTags ?? []).join(", "));
|
|
||||||
}, [initialTags, path]);
|
|
||||||
|
|
||||||
const historyQuery = useQuery({
|
|
||||||
queryKey: ["tags-history", path],
|
|
||||||
queryFn: () => fetchHistory(path),
|
|
||||||
enabled: open && !!path,
|
|
||||||
});
|
|
||||||
|
|
||||||
const mutation = useMutation({
|
|
||||||
mutationFn: (tags: string[]) => saveTags(path, tags),
|
|
||||||
onSuccess: (data) => {
|
|
||||||
toast.success("Tags saved");
|
|
||||||
onSaved?.(data.tags);
|
|
||||||
onOpenChange(false);
|
|
||||||
},
|
|
||||||
onError: (err: unknown) => toast.error(err instanceof Error ? err.message : String(err)),
|
|
||||||
});
|
|
||||||
|
|
||||||
const currentTags = parseTags(value);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog open={open} onOpenChange={(v) => !mutation.isPending && onOpenChange(v)}>
|
|
||||||
<DialogContent className="sm:max-w-lg">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>Tags</DialogTitle>
|
|
||||||
<div className="text-xs text-muted-foreground break-all mt-1">
|
|
||||||
File: <code>{path}</code>
|
|
||||||
</div>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
<div className="space-y-3">
|
|
||||||
<div>
|
|
||||||
<label className="text-sm font-medium">Edit tags (comma-separated)</label>
|
|
||||||
<Input
|
|
||||||
value={value}
|
|
||||||
onChange={(e) => setValue(e.target.value)}
|
|
||||||
placeholder="tag1, tag2, tag3"
|
|
||||||
disabled={mutation.isPending}
|
|
||||||
/>
|
|
||||||
<div className="flex flex-wrap gap-2 mt-2">
|
|
||||||
{currentTags.map((t) => (
|
|
||||||
<Badge key={t} variant="secondary">{t}</Badge>
|
|
||||||
))}
|
|
||||||
{currentTags.length === 0 && (
|
|
||||||
<div className="text-xs text-muted-foreground">No tags</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<div className="text-sm font-medium mb-1">History</div>
|
|
||||||
<div className="max-h-48 overflow-auto border rounded p-2 text-sm">
|
|
||||||
{historyQuery.isLoading && <div className="text-muted-foreground">Loading history…</div>}
|
|
||||||
{historyQuery.error && (
|
|
||||||
<div className="text-destructive">
|
|
||||||
{(historyQuery.error as Error).message}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{!historyQuery.isLoading && !historyQuery.error && (
|
|
||||||
<ul className="space-y-1">
|
|
||||||
{(historyQuery.data?.items ?? []).map((ev, idx) => (
|
|
||||||
<li key={ev.id ?? idx} className="flex items-center gap-2">
|
|
||||||
<span className="text-xs text-muted-foreground">{ev.at ? new Date(ev.at).toLocaleString() : ""}</span>
|
|
||||||
<span>→</span>
|
|
||||||
<span className="flex flex-wrap gap-1">
|
|
||||||
{(ev.to ?? []).map((t) => (
|
|
||||||
<Badge key={t} variant="outline">{t}</Badge>
|
|
||||||
))}
|
|
||||||
{(ev.to ?? []).length === 0 && (
|
|
||||||
<span className="text-xs text-muted-foreground">[]</span>
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
{(historyQuery.data?.items ?? []).length === 0 && (
|
|
||||||
<li className="text-xs text-muted-foreground">No history yet</li>
|
|
||||||
)}
|
|
||||||
</ul>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<DialogFooter>
|
|
||||||
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={mutation.isPending}>
|
|
||||||
Close
|
|
||||||
</Button>
|
|
||||||
<Button onClick={() => mutation.mutate(currentTags)} disabled={mutation.isPending}>
|
|
||||||
{mutation.isPending ? "Saving…" : "Save"}
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -67,10 +67,6 @@ const EnvSchema = z.object({
|
|||||||
OPENAI_EMBEDDING_MODEL: optionalString,
|
OPENAI_EMBEDDING_MODEL: optionalString,
|
||||||
EMBEDDING_DIM: numberFromEnv, // e.g., 1536
|
EMBEDDING_DIM: numberFromEnv, // e.g., 1536
|
||||||
|
|
||||||
// Qdrant (optional)
|
|
||||||
QDRANT_URL: optionalUrl,
|
|
||||||
QDRANT_API_KEY: optionalString,
|
|
||||||
|
|
||||||
// Sentry (optional)
|
// Sentry (optional)
|
||||||
SENTRY_DSN: optionalUrl,
|
SENTRY_DSN: optionalUrl,
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,42 +0,0 @@
|
|||||||
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? }
|
|
||||||
}
|
|
||||||
@ -178,88 +178,6 @@ export class NextcloudClient {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async moveFile(from: string, to: string): Promise<{ ok: boolean; from: string; to: string }> {
|
|
||||||
return Sentry.startSpan(
|
|
||||||
{ op: "http.client", name: "WebDAV MOVE moveFile" },
|
|
||||||
async (span) => {
|
|
||||||
const src = this.resolve(from);
|
|
||||||
const dst = this.resolve(to);
|
|
||||||
span.setAttribute("path_from", src);
|
|
||||||
span.setAttribute("path_to", dst);
|
|
||||||
const client = getClient();
|
|
||||||
await client.moveFile(src, dst, { overwrite: true });
|
|
||||||
return { ok: true, from: src, to: dst };
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async copyFile(from: string, to: string): Promise<{ ok: boolean; from: string; to: string }> {
|
|
||||||
return Sentry.startSpan(
|
|
||||||
{ op: "http.client", name: "WebDAV COPY copyFile" },
|
|
||||||
async (span) => {
|
|
||||||
const src = this.resolve(from);
|
|
||||||
const dst = this.resolve(to);
|
|
||||||
span.setAttribute("path_from", src);
|
|
||||||
span.setAttribute("path_to", dst);
|
|
||||||
const client = getClient();
|
|
||||||
await client.copyFile(src, dst, { overwrite: true });
|
|
||||||
return { ok: true, from: src, to: dst };
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async deletePath(path: string): Promise<{ ok: boolean }> {
|
|
||||||
return Sentry.startSpan(
|
|
||||||
{ op: "http.client", name: "WebDAV DELETE deletePath" },
|
|
||||||
async (span) => {
|
|
||||||
const target = this.resolve(path);
|
|
||||||
span.setAttribute("path", target);
|
|
||||||
const client = getClient();
|
|
||||||
try {
|
|
||||||
// Determine if directory or file for the correct delete call
|
|
||||||
const s = (await client.stat(target)) as FileStat;
|
|
||||||
if (s.type === "directory") {
|
|
||||||
await client.deleteFile(target); // webdav client uses deleteFile for both in many versions
|
|
||||||
} else {
|
|
||||||
await client.deleteFile(target);
|
|
||||||
}
|
|
||||||
return { ok: true };
|
|
||||||
} catch (err) {
|
|
||||||
Sentry.captureException(err);
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async readText(path: string): Promise<{ ok: boolean; content?: string; mimeType?: string }> {
|
|
||||||
return Sentry.startSpan(
|
|
||||||
{ op: "http.client", name: "WebDAV GET readText" },
|
|
||||||
async (span) => {
|
|
||||||
const target = this.resolve(path);
|
|
||||||
span.setAttribute("path", target);
|
|
||||||
const client = getClient();
|
|
||||||
const text = (await client.getFileContents(target, { format: "text" })) as unknown as string;
|
|
||||||
const s = await this.stat(target);
|
|
||||||
return { ok: true, content: text, mimeType: s?.contentType || "text/plain" };
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async writeText(path: string, content: string, mimeType?: string): Promise<{ ok: boolean; etag?: string }> {
|
|
||||||
return Sentry.startSpan(
|
|
||||||
{ op: "http.client", name: "WebDAV PUT writeText" },
|
|
||||||
async (span) => {
|
|
||||||
const target = this.resolve(path);
|
|
||||||
span.setAttribute("path", target);
|
|
||||||
if (mimeType) span.setAttribute("contentType", mimeType);
|
|
||||||
const client = getClient();
|
|
||||||
await client.putFileContents(target, content, { overwrite: true });
|
|
||||||
return { ok: true };
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const nextcloud = new NextcloudClient();
|
export const nextcloud = new NextcloudClient();
|
||||||
|
|||||||
@ -47,41 +47,3 @@ export interface WebDavEntry {
|
|||||||
contentType?: string;
|
contentType?: string;
|
||||||
props?: Record<string, unknown>;
|
props?: Record<string, unknown>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Additional payloads for CRUD and tagging flows
|
|
||||||
*/
|
|
||||||
export interface RenamePayload {
|
|
||||||
from: string; // PathId
|
|
||||||
to: string; // PathId
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CopyPayload {
|
|
||||||
from: string; // PathId
|
|
||||||
to: string; // PathId
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface DeletePayload {
|
|
||||||
path: string; // PathId
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface FileContent {
|
|
||||||
path: string; // PathId
|
|
||||||
content: string; // UTF-8 text
|
|
||||||
mimeType?: string; // default text/markdown
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface TagUpdatePayload {
|
|
||||||
path: string; // PathId
|
|
||||||
tags: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface TagHistoryEvent {
|
|
||||||
id: string; // uuid
|
|
||||||
path: string; // PathId at time of change
|
|
||||||
actor?: string; // principal (NEXTCLOUD_USERNAME)
|
|
||||||
action: "tags.update";
|
|
||||||
from?: string[]; // previous tags
|
|
||||||
to: string[]; // new tags
|
|
||||||
at: string; // ISO timestamp
|
|
||||||
}
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user