mirror of
https://github.com/trustgraph-ai/trustgraph.git
synced 2026-07-01 09:29:38 +02:00
feat: fix RAG pipelines, Beep Graph branding, PWA, and ambient glow UI
Pipeline fixes: - Fix agent getting empty response from graph-rag by combining answer + explain data in single message (RequestResponse returns first msg) - Fix Doc RAG pipeline: add content field to Qdrant doc payload, seed 10 document chunks, fix type mismatches across base/flow/client - Forward explainability events from agent's KnowledgeQuery to client - Add "agent" to TERM_BEARING_RESPONSE_SERVICES for triple translation - Fix embeddings env var (OLLAMA_URL), user/collection threading, edge scoring threshold, and various protocol mismatches Branding: - Rename TrustGraph → Beep Graph (title, sidebar, settings, about) - Custom lambda + ThugLife pixel glasses SVG logo component - Forest green color palette (brand-50 through brand-900) - SVG favicon + PNG icons (16/32/180/192/512) - PWA manifest with service worker for offline shell caching - Splash screen with animated logo pulse on app load - Ambient glow background with drifting green radial blobs Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
87f6e5eb05
commit
ee45cb4850
42 changed files with 1690 additions and 153 deletions
|
|
@ -55,10 +55,13 @@ function AgentPhaseBlock({
|
|||
content: string;
|
||||
isActive: boolean;
|
||||
}) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [manualToggle, setManualToggle] = useState<boolean | null>(null);
|
||||
|
||||
if (!content && !isActive) return null;
|
||||
|
||||
// Auto-expand while actively streaming; user can override
|
||||
const expanded = manualToggle ?? isActive;
|
||||
|
||||
const phaseColors: Record<string, string> = {
|
||||
think: "border-amber-500/30 bg-amber-500/5",
|
||||
observe: "border-sky-500/30 bg-sky-500/5",
|
||||
|
|
@ -79,7 +82,7 @@ function AgentPhaseBlock({
|
|||
)}
|
||||
>
|
||||
<button
|
||||
onClick={() => setExpanded((p) => !p)}
|
||||
onClick={() => setManualToggle((prev) => !(prev ?? isActive))}
|
||||
aria-expanded={expanded}
|
||||
className="flex w-full items-center gap-2 px-3 py-2 text-left text-xs font-medium text-fg-muted"
|
||||
>
|
||||
|
|
@ -101,9 +104,14 @@ function AgentPhaseBlock({
|
|||
<Loader2 className="ml-auto h-3 w-3 animate-spin text-fg-subtle" />
|
||||
)}
|
||||
</button>
|
||||
{expanded && content && (
|
||||
{expanded && (content || isActive) && (
|
||||
<div className="border-t border-border/50 px-3 py-2 text-xs leading-relaxed text-fg-muted">
|
||||
<p className="whitespace-pre-wrap">{content}</p>
|
||||
<p className="whitespace-pre-wrap">
|
||||
{content || (isActive ? "..." : "")}
|
||||
</p>
|
||||
{isActive && content && (
|
||||
<span className="mt-1 inline-block h-1.5 w-1.5 animate-pulse rounded-full bg-amber-400" />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import {
|
|||
ChevronRight,
|
||||
Loader2,
|
||||
AlertTriangle,
|
||||
Info,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useFlows, type FlowSummary } from "@/hooks/use-flows";
|
||||
|
|
@ -44,6 +45,9 @@ function StartFlowDialog({
|
|||
const [submitting, setSubmitting] = useState(false);
|
||||
const [paramsError, setParamsError] = useState<string | null>(null);
|
||||
const [submitted, setSubmitted] = useState(false);
|
||||
const [blueprintDef, setBlueprintDef] = useState<Record<string, unknown> | null>(null);
|
||||
const [loadingDef, setLoadingDef] = useState(false);
|
||||
const [defExpanded, setDefExpanded] = useState(false);
|
||||
|
||||
// Fetch blueprints when dialog opens
|
||||
useEffect(() => {
|
||||
|
|
@ -64,6 +68,48 @@ function StartFlowDialog({
|
|||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [open, socket]);
|
||||
|
||||
// Fetch blueprint definition when selection changes
|
||||
useEffect(() => {
|
||||
if (!blueprint) {
|
||||
setBlueprintDef(null);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
setLoadingDef(true);
|
||||
setBlueprintDef(null);
|
||||
socket
|
||||
.flows()
|
||||
.getFlowBlueprint(blueprint)
|
||||
.then((def) => {
|
||||
if (cancelled) return;
|
||||
setBlueprintDef(def);
|
||||
// Pre-populate parameters with defaults from the definition
|
||||
const paramsDef =
|
||||
def?.parameters ?? def?.params ?? def?.["parameters"] ?? def?.["params"];
|
||||
if (paramsDef && typeof paramsDef === "object") {
|
||||
const defaults: Record<string, unknown> = {};
|
||||
const params = paramsDef as Record<string, unknown>;
|
||||
for (const [key, val] of Object.entries(params)) {
|
||||
if (val && typeof val === "object" && "default" in (val as Record<string, unknown>)) {
|
||||
defaults[key] = (val as Record<string, unknown>).default;
|
||||
}
|
||||
}
|
||||
if (Object.keys(defaults).length > 0) {
|
||||
setParamsJson(JSON.stringify(defaults, null, 2));
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setBlueprintDef(null);
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoadingDef(false);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [blueprint, socket]);
|
||||
|
||||
const reset = () => {
|
||||
setId("");
|
||||
setBlueprint("");
|
||||
|
|
@ -72,6 +118,9 @@ function StartFlowDialog({
|
|||
setParamsError(null);
|
||||
setSubmitting(false);
|
||||
setSubmitted(false);
|
||||
setBlueprintDef(null);
|
||||
setLoadingDef(false);
|
||||
setDefExpanded(false);
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
|
|
@ -123,7 +172,7 @@ function StartFlowDialog({
|
|||
</button>
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={!isValid || submitting}
|
||||
disabled={submitting}
|
||||
className="flex items-center gap-2 rounded-lg bg-brand-600 px-4 py-2 text-sm font-medium text-white hover:bg-brand-500 disabled:opacity-40"
|
||||
>
|
||||
{submitting && <Loader2 className="h-3.5 w-3.5 animate-spin" />}
|
||||
|
|
@ -180,6 +229,92 @@ function StartFlowDialog({
|
|||
{submitted && !blueprint && (
|
||||
<p className="mt-1 text-xs text-red-400">Blueprint is required</p>
|
||||
)}
|
||||
|
||||
{/* Blueprint details info section */}
|
||||
{loadingDef && (
|
||||
<div className="mt-2 flex items-center gap-2 text-xs text-fg-subtle">
|
||||
<Loader2 className="h-3 w-3 animate-spin" /> Loading blueprint details...
|
||||
</div>
|
||||
)}
|
||||
|
||||
{blueprintDef && !loadingDef && (
|
||||
<div className="mt-2 rounded-lg border border-border bg-surface-50 p-3">
|
||||
<div className="flex items-center gap-1.5 text-xs font-medium text-fg-muted">
|
||||
<Info className="h-3.5 w-3.5 text-brand-400" />
|
||||
Blueprint Details
|
||||
</div>
|
||||
|
||||
{/* Description from definition */}
|
||||
{!!(blueprintDef.description || blueprintDef.desc) && (
|
||||
<p className="mt-1.5 text-xs text-fg-muted">
|
||||
{String(blueprintDef.description ?? blueprintDef.desc)}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Parameters schema */}
|
||||
{(() => {
|
||||
const paramsDef =
|
||||
blueprintDef.parameters ??
|
||||
blueprintDef.params ??
|
||||
blueprintDef["parameters"] ??
|
||||
blueprintDef["params"];
|
||||
if (!paramsDef || typeof paramsDef !== "object") return null;
|
||||
const entries = Object.entries(paramsDef as Record<string, unknown>);
|
||||
if (entries.length === 0) return null;
|
||||
return (
|
||||
<div className="mt-2">
|
||||
<p className="text-xs font-medium text-fg-muted">Parameters</p>
|
||||
<div className="mt-1 space-y-1">
|
||||
{entries.map(([name, schema]) => {
|
||||
const s = schema as Record<string, unknown> | null;
|
||||
const type = s?.type ? String(s.type) : undefined;
|
||||
const defaultVal = s && "default" in s ? s.default : undefined;
|
||||
const desc = s?.description ? String(s.description) : undefined;
|
||||
return (
|
||||
<div
|
||||
key={name}
|
||||
className="flex flex-wrap items-baseline gap-x-2 text-xs"
|
||||
>
|
||||
<span className="font-mono font-medium text-fg">{name}</span>
|
||||
{type && (
|
||||
<span className="rounded bg-surface-200 px-1 py-0.5 text-[10px] text-fg-subtle">
|
||||
{type}
|
||||
</span>
|
||||
)}
|
||||
{defaultVal !== undefined && (
|
||||
<span className="text-fg-subtle">
|
||||
default: <span className="font-mono">{JSON.stringify(defaultVal)}</span>
|
||||
</span>
|
||||
)}
|
||||
{desc && <span className="text-fg-subtle">- {desc}</span>}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* Raw JSON toggle */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDefExpanded((p) => !p)}
|
||||
className="mt-2 flex items-center gap-1 text-[11px] text-fg-subtle hover:text-fg-muted"
|
||||
>
|
||||
{defExpanded ? (
|
||||
<ChevronDown className="h-3 w-3" />
|
||||
) : (
|
||||
<ChevronRight className="h-3 w-3" />
|
||||
)}
|
||||
Raw definition
|
||||
</button>
|
||||
{defExpanded && (
|
||||
<pre className="mt-1 max-h-40 overflow-auto rounded border border-border bg-surface-100 p-2 font-mono text-[11px] text-fg-subtle">
|
||||
{JSON.stringify(blueprintDef, null, 2)}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
|
|
@ -241,25 +376,41 @@ function StopFlowDialog({
|
|||
open: boolean;
|
||||
flowId: string;
|
||||
onClose: () => void;
|
||||
onConfirm: () => void;
|
||||
onConfirm: () => Promise<void>;
|
||||
}) {
|
||||
const [stopping, setStopping] = useState(false);
|
||||
|
||||
const handleStop = async () => {
|
||||
setStopping(true);
|
||||
try {
|
||||
await onConfirm();
|
||||
} finally {
|
||||
setStopping(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
onClose={() => {
|
||||
if (!stopping) onClose();
|
||||
}}
|
||||
title="Stop Flow"
|
||||
footer={
|
||||
<>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="rounded-lg border border-border px-4 py-2 text-sm text-fg-muted hover:bg-surface-200"
|
||||
disabled={stopping}
|
||||
className="rounded-lg border border-border px-4 py-2 text-sm text-fg-muted hover:bg-surface-200 disabled:opacity-40"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={onConfirm}
|
||||
className="rounded-lg bg-error px-4 py-2 text-sm font-medium text-white hover:opacity-90"
|
||||
onClick={handleStop}
|
||||
disabled={stopping}
|
||||
className="flex items-center gap-2 rounded-lg bg-error px-4 py-2 text-sm font-medium text-white hover:opacity-90 disabled:opacity-40"
|
||||
>
|
||||
{stopping && <Loader2 className="h-3.5 w-3.5 animate-spin" />}
|
||||
Stop
|
||||
</button>
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -313,7 +313,7 @@ export default function GraphPage() {
|
|||
const zoomFit = () =>
|
||||
fgRef.current?.zoomToFit(400, 40);
|
||||
|
||||
// Node paint callback
|
||||
// Node paint callback — with glow effect
|
||||
const paintNode = useCallback(
|
||||
(node: GraphNode, ctx: CanvasRenderingContext2D, globalScale: number) => {
|
||||
const isSelected = node.id === selectedNode;
|
||||
|
|
@ -324,18 +324,40 @@ export default function GraphPage() {
|
|||
const x = node.x ?? 0;
|
||||
const y = node.y ?? 0;
|
||||
|
||||
// Node circle
|
||||
ctx.beginPath();
|
||||
ctx.arc(x, y, radius, 0, 2 * Math.PI);
|
||||
ctx.fillStyle = dim
|
||||
const baseColor = dim
|
||||
? "rgba(100,100,100,0.3)"
|
||||
: isSelected
|
||||
? "#fbbf24"
|
||||
: isMatch
|
||||
? "#22c55e"
|
||||
: node.color ?? "#5b80ff";
|
||||
|
||||
// Outer glow (only when not dimmed)
|
||||
if (!dim) {
|
||||
ctx.save();
|
||||
ctx.shadowColor = baseColor;
|
||||
ctx.shadowBlur = isSelected ? 16 : 8;
|
||||
ctx.beginPath();
|
||||
ctx.arc(x, y, radius, 0, 2 * Math.PI);
|
||||
ctx.fillStyle = baseColor;
|
||||
ctx.fill();
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
// Node circle (crisp, on top of glow)
|
||||
ctx.beginPath();
|
||||
ctx.arc(x, y, radius, 0, 2 * Math.PI);
|
||||
ctx.fillStyle = baseColor;
|
||||
ctx.fill();
|
||||
|
||||
// Inner highlight (subtle white dot for depth)
|
||||
if (!dim && radius > 3) {
|
||||
ctx.beginPath();
|
||||
ctx.arc(x - radius * 0.25, y - radius * 0.25, radius * 0.3, 0, 2 * Math.PI);
|
||||
ctx.fillStyle = "rgba(255,255,255,0.2)";
|
||||
ctx.fill();
|
||||
}
|
||||
|
||||
if (isSelected || isMatch) {
|
||||
ctx.strokeStyle = isSelected ? "#fbbf24" : "#22c55e";
|
||||
ctx.lineWidth = 1.5 / globalScale;
|
||||
|
|
@ -344,7 +366,7 @@ export default function GraphPage() {
|
|||
|
||||
// Label
|
||||
const fontSize = Math.max(10 / globalScale, 2);
|
||||
ctx.font = `${fontSize}px Inter, sans-serif`;
|
||||
ctx.font = `600 ${fontSize}px Inter, sans-serif`;
|
||||
ctx.textAlign = "center";
|
||||
ctx.textBaseline = "top";
|
||||
const isLight = document.documentElement.classList.contains("light");
|
||||
|
|
@ -353,7 +375,7 @@ export default function GraphPage() {
|
|||
: isLight
|
||||
? "rgba(24,24,27,0.9)"
|
||||
: "rgba(250,250,250,0.9)";
|
||||
ctx.fillText(node.label, x, y + radius + 1);
|
||||
ctx.fillText(node.label, x, y + radius + 2);
|
||||
},
|
||||
[selectedNode, matchingIds],
|
||||
);
|
||||
|
|
@ -631,9 +653,16 @@ export default function GraphPage() {
|
|||
}}
|
||||
linkCanvasObjectMode={() => "after"}
|
||||
linkCanvasObject={paintLink}
|
||||
linkColor={() => "rgba(91,128,255,0.25)"}
|
||||
linkDirectionalArrowLength={4}
|
||||
linkColor={() => "rgba(91,128,255,0.18)"}
|
||||
linkWidth={1.5}
|
||||
linkDirectionalArrowLength={5}
|
||||
linkDirectionalArrowRelPos={0.85}
|
||||
linkDirectionalArrowColor={() => "rgba(91,128,255,0.5)"}
|
||||
linkDirectionalParticles={2}
|
||||
linkDirectionalParticleWidth={2}
|
||||
linkDirectionalParticleSpeed={0.004}
|
||||
linkDirectionalParticleColor={() => "rgba(91,128,255,0.6)"}
|
||||
linkCurvature={0.1}
|
||||
onNodeClick={(node: GraphNode) => {
|
||||
setSelectedNode((prev) =>
|
||||
prev === node.id ? null : node.id,
|
||||
|
|
@ -641,6 +670,8 @@ export default function GraphPage() {
|
|||
}}
|
||||
onBackgroundClick={() => setSelectedNode(null)}
|
||||
backgroundColor="transparent"
|
||||
cooldownTicks={100}
|
||||
warmupTicks={30}
|
||||
width={containerSize?.width}
|
||||
height={containerSize?.height}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
LibraryBig,
|
||||
Upload,
|
||||
|
|
@ -9,15 +9,23 @@ import {
|
|||
Loader2,
|
||||
X,
|
||||
AlertTriangle,
|
||||
Search,
|
||||
Eye,
|
||||
Clock,
|
||||
Tag,
|
||||
Hash,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useLibrary } from "@/hooks/use-library";
|
||||
import { useLibrary, type UploadProgress } from "@/hooks/use-library";
|
||||
import { useSettings } from "@/providers/settings-provider";
|
||||
import { useNotification } from "@/providers/notification-provider";
|
||||
import { Dialog } from "@/components/ui/dialog";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import type { DocumentMetadata } from "@trustgraph/client";
|
||||
|
||||
// Threshold for chunked upload (1 MB base64 ~ 750 KB raw)
|
||||
const CHUNKED_UPLOAD_THRESHOLD = 1_000_000;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Upload dialog
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -26,6 +34,7 @@ function UploadDialog({
|
|||
open,
|
||||
onClose,
|
||||
onUpload,
|
||||
onUploadChunked,
|
||||
onError,
|
||||
}: {
|
||||
open: boolean;
|
||||
|
|
@ -37,6 +46,14 @@ function UploadDialog({
|
|||
comments: string,
|
||||
tags: string[],
|
||||
) => Promise<void>;
|
||||
onUploadChunked: (
|
||||
data: string,
|
||||
mimeType: string,
|
||||
title: string,
|
||||
comments: string,
|
||||
tags: string[],
|
||||
onProgress: (progress: UploadProgress) => void,
|
||||
) => Promise<void>;
|
||||
onError?: (msg: string) => void;
|
||||
}) {
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
|
|
@ -45,6 +62,7 @@ function UploadDialog({
|
|||
const [comments, setComments] = useState("");
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [dragOver, setDragOver] = useState(false);
|
||||
const [progress, setProgress] = useState<UploadProgress | null>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const reset = () => {
|
||||
|
|
@ -53,6 +71,7 @@ function UploadDialog({
|
|||
setTags("");
|
||||
setComments("");
|
||||
setUploading(false);
|
||||
setProgress(null);
|
||||
};
|
||||
|
||||
const titleRef = useRef(title);
|
||||
|
|
@ -79,15 +98,26 @@ function UploadDialog({
|
|||
.split(",")
|
||||
.map((t) => t.trim())
|
||||
.filter(Boolean);
|
||||
await onUpload(base64, file.type || "application/octet-stream", title, comments, tagList);
|
||||
const mimeType = file.type || "application/octet-stream";
|
||||
|
||||
if (base64.length > CHUNKED_UPLOAD_THRESHOLD) {
|
||||
await onUploadChunked(base64, mimeType, title, comments, tagList, setProgress);
|
||||
} else {
|
||||
await onUpload(base64, mimeType, title, comments, tagList);
|
||||
}
|
||||
reset();
|
||||
onClose();
|
||||
} catch (err) {
|
||||
onError?.(err instanceof Error ? err.message : "Upload failed");
|
||||
setUploading(false);
|
||||
setProgress(null);
|
||||
}
|
||||
};
|
||||
|
||||
const progressPercent = progress
|
||||
? Math.round((progress.chunksUploaded / Math.max(progress.chunksTotal, 1)) * 100)
|
||||
: 0;
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
|
|
@ -151,10 +181,12 @@ function UploadDialog({
|
|||
<div className="flex items-center gap-2 text-sm text-fg">
|
||||
<FileText className="h-4 w-4" />
|
||||
<span>{file.name}</span>
|
||||
<span className="text-xs text-fg-subtle">({formatBytes(file.size)})</span>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setFile(null);
|
||||
setTitle("");
|
||||
}}
|
||||
aria-label="Remove selected file"
|
||||
className="ml-1 text-fg-subtle hover:text-fg"
|
||||
|
|
@ -182,6 +214,28 @@ function UploadDialog({
|
|||
/>
|
||||
</div>
|
||||
|
||||
{/* Upload progress bar */}
|
||||
{uploading && progress && (
|
||||
<div className="mb-4 space-y-1.5">
|
||||
<div className="flex items-center justify-between text-xs text-fg-muted">
|
||||
<span>
|
||||
{progress.phase === "preparing"
|
||||
? "Preparing upload..."
|
||||
: progress.phase === "finalizing"
|
||||
? "Finalizing..."
|
||||
: `Uploading chunk ${progress.chunksUploaded}/${progress.chunksTotal}`}
|
||||
</span>
|
||||
<span>{progressPercent}%</span>
|
||||
</div>
|
||||
<div className="h-2 overflow-hidden rounded-full bg-surface-200">
|
||||
<div
|
||||
className="h-full rounded-full bg-brand-500 transition-all duration-300"
|
||||
style={{ width: `${progressPercent}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Title */}
|
||||
<div className="mb-3 space-y-1.5">
|
||||
<label htmlFor="upload-title" className="block text-sm font-medium text-fg-muted">Title</label>
|
||||
|
|
@ -225,6 +279,110 @@ function UploadDialog({
|
|||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Document detail dialog
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function DocumentDetailDialog({
|
||||
open,
|
||||
doc,
|
||||
loading: loadingMeta,
|
||||
onClose,
|
||||
}: {
|
||||
open: boolean;
|
||||
doc: DocumentMetadata | null;
|
||||
loading?: boolean;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
if (!doc) return null;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onClose={onClose} title="Document Details" className="max-w-xl">
|
||||
{loadingMeta && (
|
||||
<div className="mb-3 flex items-center gap-2 text-xs text-fg-subtle">
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
Loading full metadata...
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-4">
|
||||
{/* Title */}
|
||||
<div>
|
||||
<h3 className="mb-1 text-xs font-medium uppercase tracking-wider text-fg-subtle">Title</h3>
|
||||
<p className="text-sm text-fg">{doc.title || "Untitled"}</p>
|
||||
</div>
|
||||
|
||||
{/* ID */}
|
||||
<div>
|
||||
<h3 className="mb-1 flex items-center gap-1.5 text-xs font-medium uppercase tracking-wider text-fg-subtle">
|
||||
<Hash className="h-3 w-3" /> Document ID
|
||||
</h3>
|
||||
<p className="break-all font-mono text-xs text-fg-muted">{doc.id}</p>
|
||||
</div>
|
||||
|
||||
{/* Type */}
|
||||
<div>
|
||||
<h3 className="mb-1 text-xs font-medium uppercase tracking-wider text-fg-subtle">Type</h3>
|
||||
<Badge variant="default">{doc.kind ?? doc["document-type"] ?? "--"}</Badge>
|
||||
</div>
|
||||
|
||||
{/* Comments */}
|
||||
{doc.comments && (
|
||||
<div>
|
||||
<h3 className="mb-1 text-xs font-medium uppercase tracking-wider text-fg-subtle">Comments</h3>
|
||||
<p className="text-sm text-fg-muted">{doc.comments}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tags */}
|
||||
{doc.tags && doc.tags.length > 0 && (
|
||||
<div>
|
||||
<h3 className="mb-1 flex items-center gap-1.5 text-xs font-medium uppercase tracking-wider text-fg-subtle">
|
||||
<Tag className="h-3 w-3" /> Tags
|
||||
</h3>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{doc.tags.map((tag) => (
|
||||
<Badge key={tag} variant="info">{tag}</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Timestamp */}
|
||||
{doc.time != null && (
|
||||
<div>
|
||||
<h3 className="mb-1 flex items-center gap-1.5 text-xs font-medium uppercase tracking-wider text-fg-subtle">
|
||||
<Clock className="h-3 w-3" /> Created
|
||||
</h3>
|
||||
<p className="text-sm text-fg-muted">
|
||||
{new Date(doc.time * 1000).toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* User */}
|
||||
{doc.user && (
|
||||
<div>
|
||||
<h3 className="mb-1 text-xs font-medium uppercase tracking-wider text-fg-subtle">Uploaded by</h3>
|
||||
<p className="text-sm text-fg-muted">{doc.user}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Raw metadata (if any RDF triples) */}
|
||||
{doc.metadata && doc.metadata.length > 0 && (
|
||||
<div>
|
||||
<h3 className="mb-1 text-xs font-medium uppercase tracking-wider text-fg-subtle">
|
||||
Metadata ({doc.metadata.length} triples)
|
||||
</h3>
|
||||
<pre className="max-h-40 overflow-y-auto rounded-lg bg-surface-100 p-3 font-mono text-[10px] text-fg-muted">
|
||||
{JSON.stringify(doc.metadata, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Confirm delete dialog
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -286,14 +444,20 @@ export default function LibraryPage() {
|
|||
error,
|
||||
getDocuments,
|
||||
uploadDocument,
|
||||
uploadDocumentChunked,
|
||||
removeDocument,
|
||||
getProcessing,
|
||||
getDocumentMetadata,
|
||||
} = useLibrary();
|
||||
const collection = useSettings((s) => s.settings.collection);
|
||||
const notify = useNotification();
|
||||
|
||||
const [uploadOpen, setUploadOpen] = useState(false);
|
||||
const [deleteTarget, setDeleteTarget] = useState<DocumentMetadata | null>(null);
|
||||
const [detailDoc, setDetailDoc] = useState<DocumentMetadata | null>(null);
|
||||
const [detailOpen, setDetailOpen] = useState(false);
|
||||
const [loadingDetail, setLoadingDetail] = useState(false);
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
|
||||
// Load documents and processing on mount
|
||||
useEffect(() => {
|
||||
|
|
@ -318,8 +482,28 @@ export default function LibraryPage() {
|
|||
}
|
||||
};
|
||||
|
||||
const handleUploadChunked = async (
|
||||
data: string,
|
||||
mimeType: string,
|
||||
title: string,
|
||||
comments: string,
|
||||
tags: string[],
|
||||
onProgress: (progress: UploadProgress) => void,
|
||||
) => {
|
||||
try {
|
||||
await uploadDocumentChunked(data, mimeType, title, comments, tags, onProgress);
|
||||
notify.success("Document uploaded", `"${title}" is being processed.`);
|
||||
getProcessing();
|
||||
} catch {
|
||||
notify.error("Upload failed", "Could not upload the document.");
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!deleteTarget?.id) return;
|
||||
if (!deleteTarget?.id) {
|
||||
setDeleteTarget(null);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await removeDocument(deleteTarget.id, collection);
|
||||
notify.success("Document deleted");
|
||||
|
|
@ -329,6 +513,20 @@ export default function LibraryPage() {
|
|||
setDeleteTarget(null);
|
||||
};
|
||||
|
||||
const handleViewDetail = useCallback(
|
||||
async (doc: DocumentMetadata) => {
|
||||
setDetailDoc(doc);
|
||||
setDetailOpen(true);
|
||||
if (doc.id) {
|
||||
setLoadingDetail(true);
|
||||
const fullMeta = await getDocumentMetadata(doc.id);
|
||||
if (fullMeta) setDetailDoc(fullMeta);
|
||||
setLoadingDetail(false);
|
||||
}
|
||||
},
|
||||
[getDocumentMetadata],
|
||||
);
|
||||
|
||||
const handleRefresh = () => {
|
||||
getDocuments();
|
||||
getProcessing();
|
||||
|
|
@ -343,6 +541,24 @@ export default function LibraryPage() {
|
|||
return kind || "--";
|
||||
};
|
||||
|
||||
// Search/filter
|
||||
const searchLower = searchTerm.toLowerCase();
|
||||
const filteredDocuments = useMemo(() => {
|
||||
if (!searchLower) return documents;
|
||||
return documents.filter((doc) => {
|
||||
const title = (doc.title ?? "").toLowerCase();
|
||||
const id = (doc.id ?? "").toLowerCase();
|
||||
const tags = (doc.tags ?? []).join(" ").toLowerCase();
|
||||
const kind = (doc.kind ?? doc["document-type"] ?? "").toLowerCase();
|
||||
return (
|
||||
title.includes(searchLower) ||
|
||||
id.includes(searchLower) ||
|
||||
tags.includes(searchLower) ||
|
||||
kind.includes(searchLower)
|
||||
);
|
||||
});
|
||||
}, [documents, searchLower]);
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
{/* Header */}
|
||||
|
|
@ -374,6 +590,31 @@ export default function LibraryPage() {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{/* Search bar */}
|
||||
{documents.length > 0 && (
|
||||
<div className="relative mb-4">
|
||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-fg-subtle" />
|
||||
<input
|
||||
id="library-search"
|
||||
type="text"
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
placeholder="Search by title, tags, type, or ID..."
|
||||
aria-label="Search documents"
|
||||
className="w-full rounded-lg border border-border bg-surface-100 py-2 pl-9 pr-9 text-sm text-fg placeholder:text-fg-subtle focus:border-brand-500 focus:outline-none focus:ring-1 focus:ring-brand-500"
|
||||
/>
|
||||
{searchTerm && (
|
||||
<button
|
||||
onClick={() => setSearchTerm("")}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-fg-subtle hover:text-fg"
|
||||
aria-label="Clear search"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Processing status */}
|
||||
{processing.length > 0 && (
|
||||
<div className="mb-4 rounded-lg border border-brand-500/30 bg-brand-500/5 p-3">
|
||||
|
|
@ -416,7 +657,14 @@ export default function LibraryPage() {
|
|||
</div>
|
||||
)}
|
||||
|
||||
{documents.length > 0 && (
|
||||
{/* Search results info */}
|
||||
{searchTerm && documents.length > 0 && (
|
||||
<p className="mb-2 text-xs text-fg-subtle">
|
||||
{filteredDocuments.length} of {documents.length} documents match
|
||||
</p>
|
||||
)}
|
||||
|
||||
{filteredDocuments.length > 0 && (
|
||||
<div className="overflow-x-auto rounded-lg border border-border">
|
||||
<table className="w-full text-left text-sm">
|
||||
<thead className="border-b border-border bg-surface-100 text-fg-muted">
|
||||
|
|
@ -429,7 +677,7 @@ export default function LibraryPage() {
|
|||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{documents.map((doc) => (
|
||||
{filteredDocuments.map((doc) => (
|
||||
<tr key={doc.id} className="hover:bg-surface-100/50">
|
||||
<td className="px-4 py-3 text-fg">
|
||||
<div className="flex items-center gap-2">
|
||||
|
|
@ -454,13 +702,24 @@ export default function LibraryPage() {
|
|||
{doc.id}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
<button
|
||||
onClick={() => setDeleteTarget(doc)}
|
||||
className="rounded p-1.5 text-fg-subtle hover:bg-error/10 hover:text-error"
|
||||
title="Delete document"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<button
|
||||
onClick={() => handleViewDetail(doc)}
|
||||
className="rounded p-1.5 text-fg-subtle hover:bg-surface-200 hover:text-fg"
|
||||
title="View details"
|
||||
aria-label="View document details"
|
||||
>
|
||||
<Eye className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setDeleteTarget(doc)}
|
||||
className="rounded p-1.5 text-fg-subtle hover:bg-error/10 hover:text-error"
|
||||
title="Delete document"
|
||||
aria-label="Delete document"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
|
|
@ -469,11 +728,20 @@ export default function LibraryPage() {
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* Empty search results */}
|
||||
{searchTerm && filteredDocuments.length === 0 && documents.length > 0 && (
|
||||
<div className="flex flex-1 flex-col items-center justify-center py-12">
|
||||
<Search className="mb-3 h-8 w-8 text-fg-subtle opacity-30" />
|
||||
<p className="text-fg-subtle">No documents match "{searchTerm}"</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Dialogs */}
|
||||
<UploadDialog
|
||||
open={uploadOpen}
|
||||
onClose={() => setUploadOpen(false)}
|
||||
onUpload={handleUpload}
|
||||
onUploadChunked={handleUploadChunked}
|
||||
onError={(msg) => notify.error("Upload failed", msg)}
|
||||
/>
|
||||
|
||||
|
|
@ -483,6 +751,16 @@ export default function LibraryPage() {
|
|||
onClose={() => setDeleteTarget(null)}
|
||||
onConfirm={handleDelete}
|
||||
/>
|
||||
|
||||
<DocumentDetailDialog
|
||||
open={detailOpen}
|
||||
doc={detailDoc}
|
||||
loading={loadingDetail}
|
||||
onClose={() => {
|
||||
setDetailOpen(false);
|
||||
setDetailDoc(null);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -504,3 +782,9 @@ function fileToBase64(file: File): Promise<string> {
|
|||
reader.readAsDataURL(file);
|
||||
});
|
||||
}
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,6 +12,8 @@ import {
|
|||
Loader2,
|
||||
Moon,
|
||||
Sun,
|
||||
Plus,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useSettings } from "@/providers/settings-provider";
|
||||
|
|
@ -21,6 +23,7 @@ import { useFlows } from "@/hooks/use-flows";
|
|||
import { useSessionStore } from "@/hooks/use-session-store";
|
||||
import { useNotification } from "@/providers/notification-provider";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Dialog } from "@/components/ui/dialog";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
|
|
@ -82,6 +85,18 @@ export default function SettingsPage() {
|
|||
>([]);
|
||||
const [loadingCollections, setLoadingCollections] = useState(false);
|
||||
|
||||
// Create-collection dialog state
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [newId, setNewId] = useState("");
|
||||
const [newName, setNewName] = useState("");
|
||||
const [newDescription, setNewDescription] = useState("");
|
||||
const [newTags, setNewTags] = useState("");
|
||||
const [creating, setCreating] = useState(false);
|
||||
|
||||
// Delete-collection confirmation dialog state
|
||||
const [deleteOpen, setDeleteOpen] = useState(false);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
|
||||
// Dark mode toggle -- uses a class on <html>/<body> and persists to localStorage
|
||||
const [isDark, setIsDark] = useState(() => {
|
||||
if (typeof window === "undefined") return true;
|
||||
|
|
@ -106,32 +121,118 @@ export default function SettingsPage() {
|
|||
}
|
||||
}, [isDark]);
|
||||
|
||||
// Fetch collections
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
// Reusable function to fetch collections from the backend
|
||||
const refreshCollections = useCallback(() => {
|
||||
setLoadingCollections(true);
|
||||
socket
|
||||
return socket
|
||||
.collectionManagement()
|
||||
.listCollections()
|
||||
.then((cols) => {
|
||||
if (!cancelled) {
|
||||
setCollections(
|
||||
Array.isArray(cols)
|
||||
? (cols as Array<{ id?: string; name?: string; [key: string]: unknown }>)
|
||||
: [],
|
||||
);
|
||||
const list = Array.isArray(cols)
|
||||
? (cols as Array<{ id?: string; collection?: string; name?: string; [key: string]: unknown }>)
|
||||
: [];
|
||||
// Ensure "default" collection is always present
|
||||
const hasDefault = list.some(
|
||||
(c) => (c.collection ?? c.id ?? c.name) === "default",
|
||||
);
|
||||
if (!hasDefault) {
|
||||
list.unshift({ id: "default", collection: "default", name: "default" });
|
||||
}
|
||||
setCollections(list);
|
||||
return list;
|
||||
})
|
||||
.catch(() => {
|
||||
/* silent -- collections endpoint may not be available */
|
||||
// Fallback: at minimum show "default"
|
||||
setCollections([{ id: "default", collection: "default", name: "default" }]);
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoadingCollections(false);
|
||||
setLoadingCollections(false);
|
||||
});
|
||||
}, [socket]);
|
||||
|
||||
// Fetch collections on mount
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
refreshCollections().then(() => {
|
||||
if (cancelled) return;
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [socket]);
|
||||
}, [refreshCollections]);
|
||||
|
||||
// Create a new collection
|
||||
const handleCreateCollection = useCallback(async () => {
|
||||
const trimmedId = newId.trim();
|
||||
if (!trimmedId) return;
|
||||
|
||||
setCreating(true);
|
||||
try {
|
||||
const tags = newTags
|
||||
.split(",")
|
||||
.map((t) => t.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
await socket
|
||||
.collectionManagement()
|
||||
.updateCollection(
|
||||
trimmedId,
|
||||
newName.trim() || undefined,
|
||||
newDescription.trim() || undefined,
|
||||
tags.length > 0 ? tags : undefined,
|
||||
);
|
||||
|
||||
await refreshCollections();
|
||||
updateSetting("collection", trimmedId);
|
||||
notify.success("Collection created", `"${newName.trim() || trimmedId}" is now active.`);
|
||||
|
||||
// Reset form and close
|
||||
setNewId("");
|
||||
setNewName("");
|
||||
setNewDescription("");
|
||||
setNewTags("");
|
||||
setCreateOpen(false);
|
||||
} catch (err) {
|
||||
notify.error(
|
||||
"Failed to create collection",
|
||||
err instanceof Error ? err.message : String(err),
|
||||
);
|
||||
} finally {
|
||||
setCreating(false);
|
||||
}
|
||||
}, [newId, newName, newDescription, newTags, socket, refreshCollections, updateSetting, notify]);
|
||||
|
||||
// Delete the current collection
|
||||
const handleDeleteCollection = useCallback(async () => {
|
||||
const currentId = settings.collection;
|
||||
if (!currentId) return;
|
||||
|
||||
setDeleting(true);
|
||||
try {
|
||||
await socket.collectionManagement().deleteCollection(currentId);
|
||||
await refreshCollections();
|
||||
|
||||
// Switch to the first remaining collection
|
||||
const remaining = collections.filter((c) => {
|
||||
const id = c.id ?? String(c.name ?? c);
|
||||
return id !== currentId;
|
||||
});
|
||||
if (remaining.length > 0) {
|
||||
const firstId = remaining[0].id ?? String(remaining[0].name ?? remaining[0]);
|
||||
updateSetting("collection", firstId);
|
||||
}
|
||||
|
||||
notify.success("Collection deleted", `"${currentId}" has been removed.`);
|
||||
setDeleteOpen(false);
|
||||
} catch (err) {
|
||||
notify.error(
|
||||
"Failed to delete collection",
|
||||
err instanceof Error ? err.message : String(err),
|
||||
);
|
||||
} finally {
|
||||
setDeleting(false);
|
||||
}
|
||||
}, [settings.collection, socket, refreshCollections, collections, updateSetting, notify]);
|
||||
|
||||
// Connection status helpers
|
||||
const isConnected =
|
||||
|
|
@ -183,7 +284,7 @@ export default function SettingsPage() {
|
|||
className="w-full rounded-lg border border-border bg-surface-100 px-4 py-2 text-sm text-fg placeholder:text-fg-subtle focus:border-brand-500 focus:outline-none focus:ring-1 focus:ring-brand-500"
|
||||
/>
|
||||
<p className="text-xs text-fg-subtle">
|
||||
The WebSocket URL for the TrustGraph gateway.
|
||||
The WebSocket URL for the Beep Graph gateway.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
|
@ -253,33 +354,193 @@ export default function SettingsPage() {
|
|||
collections...
|
||||
</div>
|
||||
) : collections.length > 0 ? (
|
||||
<select
|
||||
id="settings-collection"
|
||||
value={settings.collection}
|
||||
onChange={(e) => updateSetting("collection", e.target.value)}
|
||||
className="w-full rounded-lg border border-border bg-surface-100 px-4 py-2 text-sm text-fg focus:border-brand-500 focus:outline-none focus:ring-1 focus:ring-brand-500"
|
||||
>
|
||||
{collections.map((c) => {
|
||||
const id = c.id ?? String(c.name ?? c);
|
||||
return (
|
||||
<option key={id} value={id}>
|
||||
{c.name ?? id}
|
||||
</option>
|
||||
);
|
||||
})}
|
||||
</select>
|
||||
<div className="flex items-center gap-2">
|
||||
<select
|
||||
id="settings-collection"
|
||||
value={settings.collection}
|
||||
onChange={(e) => updateSetting("collection", e.target.value)}
|
||||
className="flex-1 rounded-lg border border-border bg-surface-100 px-4 py-2 text-sm text-fg focus:border-brand-500 focus:outline-none focus:ring-1 focus:ring-brand-500"
|
||||
>
|
||||
{collections.map((c) => {
|
||||
const cObj = c as { collection?: string; id?: string; name?: string };
|
||||
const collId = cObj.collection ?? cObj.id ?? String(cObj.name ?? c);
|
||||
const label = cObj.name ?? collId;
|
||||
return (
|
||||
<option key={collId} value={collId}>
|
||||
{label !== collId ? `${label} (${collId})` : collId}
|
||||
</option>
|
||||
);
|
||||
})}
|
||||
</select>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCreateOpen(true)}
|
||||
aria-label="New collection"
|
||||
title="New collection"
|
||||
className="rounded-lg border border-border bg-surface-100 p-2 text-fg-subtle hover:bg-surface-200 hover:text-fg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-500"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
</button>
|
||||
{collections.length > 1 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDeleteOpen(true)}
|
||||
aria-label="Delete collection"
|
||||
title="Delete collection"
|
||||
className="rounded-lg border border-red-500/30 bg-surface-100 p-2 text-red-400 hover:bg-red-500/10 hover:text-red-300 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-red-500"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<input
|
||||
id="settings-collection"
|
||||
type="text"
|
||||
value={settings.collection}
|
||||
onChange={(e) => updateSetting("collection", e.target.value)}
|
||||
className="w-full rounded-lg border border-border bg-surface-100 px-4 py-2 text-sm text-fg placeholder:text-fg-subtle focus:border-brand-500 focus:outline-none focus:ring-1 focus:ring-brand-500"
|
||||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
id="settings-collection"
|
||||
type="text"
|
||||
value={settings.collection}
|
||||
onChange={(e) => updateSetting("collection", e.target.value)}
|
||||
className="flex-1 rounded-lg border border-border bg-surface-100 px-4 py-2 text-sm text-fg placeholder:text-fg-subtle focus:border-brand-500 focus:outline-none focus:ring-1 focus:ring-brand-500"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCreateOpen(true)}
|
||||
aria-label="New collection"
|
||||
title="New collection"
|
||||
className="rounded-lg border border-border bg-surface-100 p-2 text-fg-subtle hover:bg-surface-200 hover:text-fg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-500"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{/* Create Collection Dialog */}
|
||||
<Dialog
|
||||
open={createOpen}
|
||||
onClose={() => setCreateOpen(false)}
|
||||
title="New Collection"
|
||||
footer={
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCreateOpen(false)}
|
||||
className="rounded-lg border border-border px-4 py-2 text-sm text-fg-muted hover:bg-surface-200"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!newId.trim() || creating}
|
||||
onClick={handleCreateCollection}
|
||||
className="inline-flex items-center gap-2 rounded-lg bg-brand-600 px-4 py-2 text-sm font-medium text-white hover:bg-brand-500 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{creating && <Loader2 className="h-3 w-3 animate-spin" />}
|
||||
Create
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-1.5">
|
||||
<label htmlFor="new-collection-id" className="block text-sm font-medium text-fg-muted">
|
||||
Collection ID <span className="text-red-400">*</span>
|
||||
</label>
|
||||
<input
|
||||
id="new-collection-id"
|
||||
type="text"
|
||||
value={newId}
|
||||
onChange={(e) => setNewId(e.target.value)}
|
||||
placeholder="my-collection"
|
||||
className="w-full rounded-lg border border-border bg-surface-100 px-4 py-2 text-sm text-fg placeholder:text-fg-subtle focus:border-brand-500 focus:outline-none focus:ring-1 focus:ring-brand-500"
|
||||
/>
|
||||
<p className="text-xs text-fg-subtle">
|
||||
A unique identifier for this collection.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label htmlFor="new-collection-name" className="block text-sm font-medium text-fg-muted">
|
||||
Display Name
|
||||
</label>
|
||||
<input
|
||||
id="new-collection-name"
|
||||
type="text"
|
||||
value={newName}
|
||||
onChange={(e) => setNewName(e.target.value)}
|
||||
placeholder="My Collection"
|
||||
className="w-full rounded-lg border border-border bg-surface-100 px-4 py-2 text-sm text-fg placeholder:text-fg-subtle focus:border-brand-500 focus:outline-none focus:ring-1 focus:ring-brand-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label htmlFor="new-collection-description" className="block text-sm font-medium text-fg-muted">
|
||||
Description
|
||||
</label>
|
||||
<textarea
|
||||
id="new-collection-description"
|
||||
value={newDescription}
|
||||
onChange={(e) => setNewDescription(e.target.value)}
|
||||
placeholder="What this collection is for..."
|
||||
rows={3}
|
||||
className="w-full rounded-lg border border-border bg-surface-100 px-4 py-2 text-sm text-fg placeholder:text-fg-subtle focus:border-brand-500 focus:outline-none focus:ring-1 focus:ring-brand-500 resize-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label htmlFor="new-collection-tags" className="block text-sm font-medium text-fg-muted">
|
||||
Tags
|
||||
</label>
|
||||
<input
|
||||
id="new-collection-tags"
|
||||
type="text"
|
||||
value={newTags}
|
||||
onChange={(e) => setNewTags(e.target.value)}
|
||||
placeholder="research, finance, internal"
|
||||
className="w-full rounded-lg border border-border bg-surface-100 px-4 py-2 text-sm text-fg placeholder:text-fg-subtle focus:border-brand-500 focus:outline-none focus:ring-1 focus:ring-brand-500"
|
||||
/>
|
||||
<p className="text-xs text-fg-subtle">
|
||||
Comma-separated list of tags for categorization.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
|
||||
{/* Delete Collection Confirmation Dialog */}
|
||||
<Dialog
|
||||
open={deleteOpen}
|
||||
onClose={() => setDeleteOpen(false)}
|
||||
title="Delete Collection"
|
||||
className="max-w-md"
|
||||
footer={
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDeleteOpen(false)}
|
||||
className="rounded-lg border border-border px-4 py-2 text-sm text-fg-muted hover:bg-surface-200"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={deleting}
|
||||
onClick={handleDeleteCollection}
|
||||
className="inline-flex items-center gap-2 rounded-lg bg-red-600 px-4 py-2 text-sm font-medium text-white hover:bg-red-500 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{deleting && <Loader2 className="h-3 w-3 animate-spin" />}
|
||||
Delete
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<p className="text-sm text-fg-muted">
|
||||
Are you sure you want to delete the collection{" "}
|
||||
<span className="font-semibold text-fg">"{settings.collection}"</span>?
|
||||
This will remove the collection and all its data. This action cannot be undone.
|
||||
</p>
|
||||
</Dialog>
|
||||
|
||||
{/* Flow */}
|
||||
<Section
|
||||
title="Active Flow"
|
||||
|
|
@ -391,11 +652,11 @@ export default function SettingsPage() {
|
|||
>
|
||||
<div className="space-y-2 text-sm text-fg-muted">
|
||||
<p>
|
||||
<span className="font-medium text-fg">TrustGraph Workbench</span>{" "}
|
||||
<span className="font-medium text-fg">Beep Graph</span>{" "}
|
||||
v0.1.0
|
||||
</p>
|
||||
<p>
|
||||
A web-based interface for interacting with the TrustGraph
|
||||
A web-based interface for interacting with the Beep Graph
|
||||
knowledge-graph system.
|
||||
</p>
|
||||
</div>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue