mirror of
https://github.com/trustgraph-ai/trustgraph.git
synced 2026-07-01 17:39:39 +02:00
Advance TS port Effect workbench
This commit is contained in:
parent
92dae8c374
commit
3515106670
116 changed files with 12286 additions and 9584 deletions
1673
ts/packages/workbench/src/atoms/workbench.ts
Normal file
1673
ts/packages/workbench/src/atoms/workbench.ts
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -1,220 +1,90 @@
|
|||
import { lazy, Suspense } from "react";
|
||||
import { useAtom, useAtomValue } from "@effect/atom-react";
|
||||
import { Network, ChevronRight, ChevronDown, Loader2 } from "lucide-react";
|
||||
import * as Atom from "effect/unstable/reactivity/Atom";
|
||||
import {
|
||||
lazy,
|
||||
Suspense,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import {
|
||||
Network,
|
||||
ChevronRight,
|
||||
ChevronDown,
|
||||
Maximize,
|
||||
Loader2,
|
||||
} from "lucide-react";
|
||||
import { useSocket } from "@/providers/socket-provider";
|
||||
import { useSessionStore } from "@/hooks/use-session-store";
|
||||
explainTriplesAtom,
|
||||
flowIdAtom,
|
||||
resultData,
|
||||
resultError,
|
||||
resultLoading,
|
||||
} from "@/atoms/workbench";
|
||||
import {
|
||||
triplesToGraph,
|
||||
localName,
|
||||
hashColor,
|
||||
type GraphNode,
|
||||
type GraphLink,
|
||||
} from "@/lib/graph-utils";
|
||||
import type { ExplainEvent, Triple } from "@trustgraph/client";
|
||||
import type { ForceGraphMethods, ForceGraphProps } from "react-force-graph-2d";
|
||||
import type { ExplainEvent } from "@trustgraph/client";
|
||||
import type { ForceGraphProps } from "react-force-graph-2d";
|
||||
import type * as React from "react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Lazy-load ForceGraph2D (shares the same chunk as the graph page)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const ForceGraph2D = lazy(() => import("react-force-graph-2d")) as unknown as React.ComponentType<ForceGraphProps<any, any> & { ref?: React.Ref<any> }>;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Component
|
||||
// ---------------------------------------------------------------------------
|
||||
const ForceGraph2D = lazy(() => import("react-force-graph-2d")) as unknown as React.ComponentType<ForceGraphProps<GraphNode, GraphLink>>;
|
||||
const explainExpandedAtom = Atom.make<Record<string, boolean>>({}).pipe(Atom.keepAlive);
|
||||
|
||||
interface ExplainGraphProps {
|
||||
explainEvents: ExplainEvent[];
|
||||
collection: string;
|
||||
}
|
||||
|
||||
function paintNode(node: GraphNode, ctx: CanvasRenderingContext2D, globalScale: number) {
|
||||
const radius = Math.max(2.5, Math.sqrt(node.degree + 1) * 2);
|
||||
const x = node.x ?? 0;
|
||||
const y = node.y ?? 0;
|
||||
ctx.beginPath();
|
||||
ctx.arc(x, y, radius, 0, 2 * Math.PI);
|
||||
ctx.fillStyle = node.color ?? "#5b80ff";
|
||||
ctx.fill();
|
||||
const fontSize = Math.max(9 / globalScale, 1.5);
|
||||
ctx.font = `${fontSize}px Inter, sans-serif`;
|
||||
ctx.textAlign = "center";
|
||||
ctx.textBaseline = "top";
|
||||
const isLight = document.documentElement.classList.contains("light");
|
||||
ctx.fillStyle = isLight ? "rgba(24,24,27,0.85)" : "rgba(250,250,250,0.85)";
|
||||
ctx.fillText(node.label, x, y + radius + 1);
|
||||
}
|
||||
|
||||
function paintLink(link: GraphLink, ctx: CanvasRenderingContext2D, globalScale: number) {
|
||||
if (globalScale < 1.5) return;
|
||||
const source = link.source as unknown as GraphNode;
|
||||
const target = link.target as unknown as GraphNode;
|
||||
if (source.x === undefined || source.y === undefined || target.x === undefined || target.y === undefined) return;
|
||||
const midX = (source.x + target.x) / 2;
|
||||
const midY = (source.y + target.y) / 2;
|
||||
const fontSize = Math.max(7 / globalScale, 1.5);
|
||||
ctx.font = `${fontSize}px Inter, sans-serif`;
|
||||
ctx.textAlign = "center";
|
||||
ctx.textBaseline = "middle";
|
||||
ctx.fillStyle = "rgba(161,161,170,0.6)";
|
||||
ctx.fillText(link.label, midX, midY);
|
||||
}
|
||||
|
||||
export function ExplainGraph({ explainEvents, collection }: ExplainGraphProps) {
|
||||
const socket = useSocket();
|
||||
const flowId = useSessionStore((s) => s.flowId);
|
||||
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [triples, setTriples] = useState<Triple[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [fetched, setFetched] = useState(false);
|
||||
|
||||
const fgRef = useRef<ForceGraphMethods<GraphNode, GraphLink> | undefined>(
|
||||
undefined,
|
||||
);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [containerWidth, setContainerWidth] = useState(0);
|
||||
|
||||
// Track container width for the force graph
|
||||
useEffect(() => {
|
||||
if (!expanded || containerRef.current === null) return;
|
||||
const ro = new ResizeObserver((entries) => {
|
||||
const entry = entries[0];
|
||||
if (entry !== undefined) setContainerWidth(Math.floor(entry.contentRect.width));
|
||||
});
|
||||
ro.observe(containerRef.current);
|
||||
return () => ro.disconnect();
|
||||
}, [expanded]);
|
||||
|
||||
// Load triples when first expanded — use inline triples if available, otherwise fetch
|
||||
useEffect(() => {
|
||||
if (!expanded || fetched) return;
|
||||
setFetched(true);
|
||||
|
||||
// Check if any explain events have inline triples
|
||||
const inlineTriples = explainEvents.flatMap((ev) => ev.explainTriples ?? []);
|
||||
if (inlineTriples.length > 0) {
|
||||
setTriples(inlineTriples);
|
||||
return;
|
||||
}
|
||||
|
||||
// Fall back to fetching from named graph
|
||||
const graphUris = explainEvents.filter(
|
||||
(ev): ev is ExplainEvent & { explainGraph: string } =>
|
||||
ev.explainGraph !== undefined && ev.explainGraph.length > 0,
|
||||
);
|
||||
if (graphUris.length === 0) return;
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const flow = socket.flow(flowId);
|
||||
|
||||
Promise.all(
|
||||
graphUris.map((ev) =>
|
||||
flow
|
||||
.triplesQuery(undefined, undefined, undefined, 500, collection, ev.explainGraph)
|
||||
.catch(() => [] as Triple[]),
|
||||
),
|
||||
)
|
||||
.then((results) => {
|
||||
setTriples(results.flat());
|
||||
})
|
||||
.catch((err) => {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
})
|
||||
.finally(() => {
|
||||
setLoading(false);
|
||||
});
|
||||
}, [expanded, fetched, explainEvents, socket, flowId, collection]);
|
||||
|
||||
// Build graph data
|
||||
const { data: graphData, typeMap } = useMemo(
|
||||
() => triplesToGraph(triples),
|
||||
[triples],
|
||||
);
|
||||
|
||||
// Auto-fit once data loads
|
||||
const hasAutoFit = useRef(false);
|
||||
useEffect(() => {
|
||||
if (
|
||||
graphData.nodes.length > 0 &&
|
||||
fgRef.current !== undefined &&
|
||||
hasAutoFit.current === false
|
||||
) {
|
||||
hasAutoFit.current = true;
|
||||
const timer = setTimeout(() => fgRef.current?.zoomToFit(400, 20), 500);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [graphData.nodes.length]);
|
||||
|
||||
// Node painting (simplified version of graph page)
|
||||
const paintNode = useCallback(
|
||||
(node: GraphNode, ctx: CanvasRenderingContext2D, globalScale: number) => {
|
||||
const radius = Math.max(2.5, Math.sqrt(node.degree + 1) * 2);
|
||||
const x = node.x ?? 0;
|
||||
const y = node.y ?? 0;
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.arc(x, y, radius, 0, 2 * Math.PI);
|
||||
ctx.fillStyle = node.color ?? "#5b80ff";
|
||||
ctx.fill();
|
||||
|
||||
const fontSize = Math.max(9 / globalScale, 1.5);
|
||||
ctx.font = `${fontSize}px Inter, sans-serif`;
|
||||
ctx.textAlign = "center";
|
||||
ctx.textBaseline = "top";
|
||||
const isLight = document.documentElement.classList.contains("light");
|
||||
ctx.fillStyle = isLight
|
||||
? "rgba(24,24,27,0.85)"
|
||||
: "rgba(250,250,250,0.85)";
|
||||
ctx.fillText(node.label, x, y + radius + 1);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
// Link label painting
|
||||
const paintLink = useCallback(
|
||||
(link: GraphLink, ctx: CanvasRenderingContext2D, globalScale: number) => {
|
||||
if (globalScale < 1.5) return;
|
||||
const src = link.source as unknown as GraphNode;
|
||||
const tgt = link.target as unknown as GraphNode;
|
||||
if (
|
||||
src.x === undefined ||
|
||||
src.y === undefined ||
|
||||
tgt.x === undefined ||
|
||||
tgt.y === undefined
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const midX = ((src.x ?? 0) + (tgt.x ?? 0)) / 2;
|
||||
const midY = ((src.y ?? 0) + (tgt.y ?? 0)) / 2;
|
||||
|
||||
const fontSize = Math.max(7 / globalScale, 1.5);
|
||||
ctx.font = `${fontSize}px Inter, sans-serif`;
|
||||
ctx.textAlign = "center";
|
||||
ctx.textBaseline = "middle";
|
||||
ctx.fillStyle = "rgba(161,161,170,0.6)";
|
||||
ctx.fillText(link.label, midX, midY);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
// Compute unique types for mini legend
|
||||
const uniqueTypes = useMemo(() => {
|
||||
const seen = new Map<string, string>();
|
||||
for (const [, typeUri] of typeMap) {
|
||||
const name = localName(typeUri);
|
||||
if (!seen.has(name)) {
|
||||
seen.set(name, typeUri);
|
||||
}
|
||||
}
|
||||
return Array.from(seen.entries());
|
||||
}, [typeMap]);
|
||||
const flowId = useAtomValue(flowIdAtom);
|
||||
const [expandedMap, setExpandedMap] = useAtom(explainExpandedAtom);
|
||||
const key = `${flowId}:${collection}:${explainEvents.map((event) => event.explainGraph ?? event.explainId).join("|")}`;
|
||||
const expanded = expandedMap[key];
|
||||
const result = useAtomValue(explainTriplesAtom({ events: explainEvents, flowId, collection }));
|
||||
const triples = resultData(result, []);
|
||||
const loading = expanded && resultLoading(result, triples);
|
||||
const error = resultError(result);
|
||||
const { data: graphData, typeMap } = triplesToGraph(triples);
|
||||
const uniqueTypes = Array.from(new Set(Array.from(typeMap.values()).map(localName))).sort();
|
||||
|
||||
return (
|
||||
<div className="mt-2 rounded-md border border-border/50">
|
||||
{/* Toggle header */}
|
||||
<button
|
||||
onClick={() => setExpanded((p) => !p)}
|
||||
onClick={() => setExpandedMap({ ...expandedMap, [key]: !expanded })}
|
||||
aria-expanded={expanded}
|
||||
className="flex w-full items-center gap-2 px-3 py-2 text-left text-xs font-medium text-fg-muted hover:bg-surface-100/50"
|
||||
>
|
||||
{expanded ? (
|
||||
<ChevronDown className="h-3 w-3 shrink-0" />
|
||||
) : (
|
||||
<ChevronRight className="h-3 w-3 shrink-0" />
|
||||
)}
|
||||
{expanded ? <ChevronDown className="h-3 w-3 shrink-0" /> : <ChevronRight className="h-3 w-3 shrink-0" />}
|
||||
<Network className="h-3 w-3 shrink-0 text-brand-400" />
|
||||
<span>View source graph</span>
|
||||
<Badge variant="info">{explainEvents.length} subgraph{explainEvents.length > 1 ? "s" : ""}</Badge>
|
||||
</button>
|
||||
|
||||
{/* Expanded content */}
|
||||
{expanded && (
|
||||
<div className="border-t border-border/50">
|
||||
{loading && (
|
||||
|
|
@ -223,100 +93,32 @@ export function ExplainGraph({ explainEvents, collection }: ExplainGraphProps) {
|
|||
Loading source graph...
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error !== null && (
|
||||
<p className="px-3 py-3 text-xs text-error">
|
||||
Failed to load graph: {error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{error !== null && <p className="px-3 py-3 text-xs text-error">Failed to load graph: {error}</p>}
|
||||
{!loading && error === null && graphData.nodes.length === 0 && (
|
||||
<p className="px-3 py-4 text-center text-xs text-fg-subtle">
|
||||
No graph data available for this query.
|
||||
</p>
|
||||
<p className="px-3 py-4 text-center text-xs text-fg-subtle">No graph data available for this query.</p>
|
||||
)}
|
||||
|
||||
{!loading && graphData.nodes.length > 0 && (
|
||||
<>
|
||||
{/* Graph info bar */}
|
||||
<div className="flex items-center justify-between px-3 py-1.5 text-[10px] text-fg-subtle">
|
||||
<span>
|
||||
{graphData.nodes.length} nodes, {graphData.links.length} edges
|
||||
</span>
|
||||
<button
|
||||
onClick={() => fgRef.current?.zoomToFit(400, 20)}
|
||||
className="rounded p-1 hover:bg-surface-200 hover:text-fg"
|
||||
title="Fit to view"
|
||||
aria-label="Fit to view"
|
||||
>
|
||||
<Maximize className="h-3 w-3" />
|
||||
</button>
|
||||
<span>{graphData.nodes.length} nodes, {graphData.links.length} edges</span>
|
||||
<div className="flex gap-1">
|
||||
{uniqueTypes.slice(0, 4).map((type) => <Badge key={type} variant="info">{type}</Badge>)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mini graph canvas */}
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="relative bg-surface-0"
|
||||
style={{ height: 280 }}
|
||||
>
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<Loader2 className="h-4 w-4 animate-spin text-fg-subtle" />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="relative h-[280px] overflow-hidden bg-surface-0">
|
||||
<Suspense fallback={<div className="flex h-full items-center justify-center text-xs text-fg-subtle">Loading graph...</div>}>
|
||||
<ForceGraph2D
|
||||
ref={fgRef}
|
||||
graphData={graphData}
|
||||
width={600}
|
||||
height={280}
|
||||
backgroundColor="rgba(0,0,0,0)"
|
||||
nodeCanvasObject={paintNode}
|
||||
nodePointerAreaPaint={(node: GraphNode, color, ctx) => {
|
||||
const radius = Math.max(
|
||||
2.5,
|
||||
Math.sqrt(node.degree + 1) * 2,
|
||||
);
|
||||
ctx.beginPath();
|
||||
ctx.arc(
|
||||
node.x ?? 0,
|
||||
node.y ?? 0,
|
||||
radius + 2,
|
||||
0,
|
||||
2 * Math.PI,
|
||||
);
|
||||
ctx.fillStyle = color;
|
||||
ctx.fill();
|
||||
}}
|
||||
linkCanvasObjectMode={() => "after"}
|
||||
linkCanvasObject={paintLink}
|
||||
linkColor={() => "rgba(91,128,255,0.25)"}
|
||||
linkDirectionalArrowLength={3}
|
||||
linkDirectionalArrowRelPos={0.85}
|
||||
backgroundColor="transparent"
|
||||
{...(containerWidth > 0 ? { width: containerWidth } : {})}
|
||||
height={280}
|
||||
linkColor={() => "rgba(120,120,140,0.32)"}
|
||||
/>
|
||||
</Suspense>
|
||||
</div>
|
||||
|
||||
{/* Mini type legend */}
|
||||
{uniqueTypes.length > 0 && (
|
||||
<div className="flex flex-wrap gap-x-3 gap-y-1 border-t border-border/50 px-3 py-2">
|
||||
{uniqueTypes.slice(0, 8).map(([name]) => (
|
||||
<div key={name} className="flex items-center gap-1.5 text-[10px] text-fg-subtle">
|
||||
<span
|
||||
className="inline-block h-2 w-2 rounded-full"
|
||||
style={{ backgroundColor: hashColor(name) }}
|
||||
/>
|
||||
<span>{name}</span>
|
||||
</div>
|
||||
))}
|
||||
{uniqueTypes.length > 8 && (
|
||||
<span className="text-[10px] text-fg-subtle">
|
||||
+{uniqueTypes.length - 8} more
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,9 +1,11 @@
|
|||
import { useState, useCallback } from "react";
|
||||
import { useAtomSet, useAtomValue } from "@effect/atom-react";
|
||||
import { Copy, Check, Trash2, RotateCcw } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { copiedMessageIdAtom, copyMessageAtom } from "@/atoms/workbench";
|
||||
|
||||
interface MessageActionsProps {
|
||||
content: string;
|
||||
messageId: string;
|
||||
isLastAssistant: boolean;
|
||||
onDelete: () => void;
|
||||
onRegenerate?: () => void;
|
||||
|
|
@ -11,38 +13,25 @@ interface MessageActionsProps {
|
|||
|
||||
export function MessageActions({
|
||||
content,
|
||||
messageId,
|
||||
isLastAssistant,
|
||||
onDelete,
|
||||
onRegenerate,
|
||||
}: MessageActionsProps) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const copiedMessageId = useAtomValue(copiedMessageIdAtom);
|
||||
const copyMessage = useAtomSet(copyMessageAtom);
|
||||
const copied = copiedMessageId === messageId;
|
||||
|
||||
const handleCopy = useCallback(async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(content);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
} catch {
|
||||
// Fallback for insecure contexts
|
||||
const textarea = document.createElement("textarea");
|
||||
textarea.value = content;
|
||||
textarea.style.position = "fixed";
|
||||
textarea.style.opacity = "0";
|
||||
document.body.appendChild(textarea);
|
||||
textarea.select();
|
||||
document.execCommand("copy");
|
||||
document.body.removeChild(textarea);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
}
|
||||
}, [content]);
|
||||
const handleCopy = () => {
|
||||
copyMessage({ id: messageId, content });
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"absolute -top-8 right-2 z-10 flex items-center gap-0.5",
|
||||
"mt-1 flex w-fit items-center gap-0.5 lg:absolute lg:-top-8 lg:right-2 lg:z-10 lg:mt-0",
|
||||
"rounded-lg border border-border bg-surface-200 px-1 py-0.5 shadow-sm",
|
||||
"pointer-events-none opacity-0 transition-opacity group-hover:pointer-events-auto group-hover:opacity-100",
|
||||
"opacity-100 transition-opacity lg:pointer-events-none lg:opacity-0 lg:group-hover:pointer-events-auto lg:group-hover:opacity-100",
|
||||
)}
|
||||
>
|
||||
<button
|
||||
|
|
|
|||
|
|
@ -1,14 +1,14 @@
|
|||
import { useAtomValue } from "@effect/atom-react";
|
||||
import { Workflow, Database } from "lucide-react";
|
||||
import { useSessionStore } from "@/hooks/use-session-store";
|
||||
import { useSettings } from "@/providers/settings-provider";
|
||||
import { flowIdAtom, settingsAtom } from "@/atoms/workbench";
|
||||
|
||||
/**
|
||||
* Compact badge showing the active flow and collection.
|
||||
* Will be expanded later into a popover picker.
|
||||
*/
|
||||
export function FlowSelector() {
|
||||
const flowId = useSessionStore((s) => s.flowId);
|
||||
const collection = useSettings((s) => s.settings.collection);
|
||||
const flowId = useAtomValue(flowIdAtom);
|
||||
const collection = useAtomValue(settingsAtom).collection;
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-4 rounded-lg border border-border bg-surface-100 px-3 py-1.5 text-xs text-fg-muted">
|
||||
|
|
|
|||
|
|
@ -1,16 +1,16 @@
|
|||
import { useAtomValue } from "@effect/atom-react";
|
||||
import { Outlet } from "react-router";
|
||||
import { WifiOff } from "lucide-react";
|
||||
import { Sidebar } from "./sidebar";
|
||||
import { FlowSelector } from "./flow-selector";
|
||||
import { GlowBackground } from "./glow-background";
|
||||
import { useProgressStore } from "@/hooks/use-progress-store";
|
||||
import { useConnectionState } from "@/providers/socket-provider";
|
||||
import { connectionStateAtom, isLoadingAtom } from "@/atoms/workbench";
|
||||
|
||||
/**
|
||||
* Top loading bar -- shown when any global activity is in progress.
|
||||
*/
|
||||
function LoadingBar() {
|
||||
const isLoading = useProgressStore((s) => s.isLoading);
|
||||
const isLoading = useAtomValue(isLoadingAtom);
|
||||
|
||||
if (!isLoading) return null;
|
||||
|
||||
|
|
@ -25,7 +25,7 @@ function LoadingBar() {
|
|||
* Root layout: fixed sidebar + scrollable main content area with a top bar.
|
||||
*/
|
||||
export function RootLayout() {
|
||||
const connectionState = useConnectionState();
|
||||
const connectionState = useAtomValue(connectionStateAtom);
|
||||
const isDisconnected =
|
||||
connectionState.status === "failed" ||
|
||||
connectionState.status === "reconnecting";
|
||||
|
|
@ -50,7 +50,7 @@ export function RootLayout() {
|
|||
<GlowBackground />
|
||||
|
||||
{/* Top bar */}
|
||||
<header className="relative z-10 flex h-14 shrink-0 items-center justify-end border-b border-border bg-surface-50/80 backdrop-blur-sm px-6">
|
||||
<header className="relative z-10 flex h-14 shrink-0 items-center justify-end border-b border-border bg-surface-50/80 px-3 backdrop-blur-sm sm:px-6">
|
||||
<FlowSelector />
|
||||
</header>
|
||||
|
||||
|
|
@ -63,7 +63,7 @@ export function RootLayout() {
|
|||
)}
|
||||
|
||||
{/* Page content */}
|
||||
<main id="main-content" className="relative z-10 flex-1 overflow-y-auto p-6">
|
||||
<main id="main-content" className="relative z-10 flex-1 overflow-y-auto p-3 sm:p-6">
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { useAtom, useAtomValue } from "@effect/atom-react";
|
||||
import { NavLink } from "react-router";
|
||||
import {
|
||||
MessageSquareText,
|
||||
|
|
@ -16,10 +17,13 @@ import {
|
|||
} from "lucide-react";
|
||||
import { BeepGraphLogo } from "./beep-graph-logo";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useConnectionState } from "@/providers/socket-provider";
|
||||
import { useSessionStore } from "@/hooks/use-session-store";
|
||||
import { useFlows } from "@/hooks/use-flows";
|
||||
import { useSettings } from "@/providers/settings-provider";
|
||||
import {
|
||||
connectionStateAtom,
|
||||
flowIdAtom,
|
||||
flowsAtom,
|
||||
resultData,
|
||||
settingsAtom,
|
||||
} from "@/atoms/workbench";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Nav item
|
||||
|
|
@ -33,18 +37,23 @@ interface NavItemProps {
|
|||
|
||||
function NavItem({ to, icon: Icon, label }: NavItemProps) {
|
||||
return (
|
||||
<NavLink to={to} className="w-full rounded-lg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-500 focus-visible:ring-offset-1 focus-visible:ring-offset-surface-50">
|
||||
<NavLink
|
||||
to={to}
|
||||
aria-label={label}
|
||||
title={label}
|
||||
className="w-full rounded-lg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-500 focus-visible:ring-offset-1 focus-visible:ring-offset-surface-50"
|
||||
>
|
||||
{({ isActive }) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium transition-colors",
|
||||
"flex items-center justify-center rounded-lg px-2 py-2 text-sm font-medium transition-colors sm:justify-start sm:gap-3 sm:px-3",
|
||||
isActive
|
||||
? "bg-brand-600/20 text-brand-400"
|
||||
: "text-fg-muted hover:bg-surface-200 hover:text-fg",
|
||||
)}
|
||||
>
|
||||
<Icon className="h-4 w-4 shrink-0" />
|
||||
<span className="truncate">{label}</span>
|
||||
<span className="hidden truncate sm:inline">{label}</span>
|
||||
</div>
|
||||
)}
|
||||
</NavLink>
|
||||
|
|
@ -56,7 +65,7 @@ function NavItem({ to, icon: Icon, label }: NavItemProps) {
|
|||
// ---------------------------------------------------------------------------
|
||||
|
||||
function ConnectionBadge() {
|
||||
const state = useConnectionState();
|
||||
const state = useAtomValue(connectionStateAtom);
|
||||
|
||||
const isConnected =
|
||||
state.status === "connected" ||
|
||||
|
|
@ -103,10 +112,9 @@ function ConnectionBadge() {
|
|||
// ---------------------------------------------------------------------------
|
||||
|
||||
function FlowSelectorDropdown() {
|
||||
const { flows } = useFlows();
|
||||
const flowId = useSessionStore((s) => s.flowId);
|
||||
const setFlowId = useSessionStore((s) => s.setFlowId);
|
||||
const collection = useSettings((s) => s.settings.collection);
|
||||
const flows = resultData(useAtomValue(flowsAtom), []);
|
||||
const [flowId, setFlowId] = useAtom(flowIdAtom);
|
||||
const collection = useAtomValue(settingsAtom).collection;
|
||||
|
||||
return (
|
||||
<div className="space-y-2 px-3">
|
||||
|
|
@ -148,26 +156,26 @@ function FlowSelectorDropdown() {
|
|||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function Sidebar() {
|
||||
const { featureSwitches } = useSettings((s) => s.settings);
|
||||
const { featureSwitches } = useAtomValue(settingsAtom);
|
||||
|
||||
return (
|
||||
<aside aria-label="Sidebar" className="flex h-screen w-sidebar shrink-0 flex-col border-r border-border bg-surface-50">
|
||||
<aside aria-label="Sidebar" className="flex h-screen w-sidebar-collapsed shrink-0 flex-col border-r border-border bg-surface-50 sm:w-sidebar">
|
||||
{/* Logo area */}
|
||||
<div className="flex h-14 items-center gap-2.5 px-4">
|
||||
<div className="flex h-14 items-center justify-center gap-2.5 px-2 sm:justify-start sm:px-4">
|
||||
<BeepGraphLogo className="h-7 w-7 shrink-0 text-brand-400" />
|
||||
<span className="text-lg font-bold text-fg">Beep Graph</span>
|
||||
<span className="hidden text-lg font-bold text-fg sm:inline">Beep Graph</span>
|
||||
</div>
|
||||
|
||||
{/* Divider */}
|
||||
<div className="mx-3 border-t border-border" />
|
||||
|
||||
{/* Flow & collection selectors */}
|
||||
<div className="py-3">
|
||||
<div className="hidden py-3 sm:block">
|
||||
<FlowSelectorDropdown />
|
||||
</div>
|
||||
|
||||
{/* Divider */}
|
||||
<div className="mx-3 border-t border-border" />
|
||||
<div className="hidden mx-3 border-t border-border sm:block" />
|
||||
|
||||
{/* Navigation links */}
|
||||
<nav aria-label="Main navigation" className="flex flex-1 flex-col gap-0.5 overflow-y-auto px-2 py-3">
|
||||
|
|
@ -185,7 +193,7 @@ export function Sidebar() {
|
|||
</nav>
|
||||
|
||||
{/* Footer: connection badge */}
|
||||
<div className="border-t border-border px-2 py-2">
|
||||
<div className="hidden border-t border-border px-2 py-2 sm:block">
|
||||
<ConnectionBadge />
|
||||
</div>
|
||||
</aside>
|
||||
|
|
|
|||
|
|
@ -1,8 +1,9 @@
|
|||
import { useAtomSet, useAtomValue } from "@effect/atom-react";
|
||||
import { X } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useNotification, type NotificationType } from "@/providers/notification-provider";
|
||||
import { notificationsAtom, removeNotificationAtom, type Notification } from "@/atoms/workbench";
|
||||
|
||||
const typeStyles: Record<NotificationType, string> = {
|
||||
const typeStyles: Record<Notification["type"], string> = {
|
||||
success: "border-success/40 bg-success/10 text-success",
|
||||
error: "border-error/40 bg-error/10 text-error",
|
||||
warning: "border-warning/40 bg-warning/10 text-warning",
|
||||
|
|
@ -13,8 +14,8 @@ const typeStyles: Record<NotificationType, string> = {
|
|||
* Renders the active notification stack in the bottom-right corner.
|
||||
*/
|
||||
export function NotificationToasts() {
|
||||
const notifications = useNotification((s) => s.notifications);
|
||||
const removeNotification = useNotification((s) => s.removeNotification);
|
||||
const notifications = useAtomValue(notificationsAtom);
|
||||
const removeNotification = useAtomSet(removeNotificationAtom);
|
||||
|
||||
if (notifications.length === 0) return null;
|
||||
|
||||
|
|
|
|||
|
|
@ -1,11 +1,4 @@
|
|||
import {
|
||||
type ReactNode,
|
||||
type MouseEvent,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useId,
|
||||
useRef,
|
||||
} from "react";
|
||||
import type { KeyboardEvent, MouseEvent, ReactNode } from "react";
|
||||
import { X } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
|
|
@ -19,11 +12,6 @@ interface DialogProps {
|
|||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Simple modal dialog built with Tailwind.
|
||||
* Renders a backdrop overlay + centered content panel.
|
||||
* Includes focus trap, auto-focus, and Escape to close.
|
||||
*/
|
||||
export function Dialog({
|
||||
open,
|
||||
onClose,
|
||||
|
|
@ -32,103 +20,24 @@ export function Dialog({
|
|||
footer,
|
||||
className,
|
||||
}: DialogProps) {
|
||||
const titleId = useId();
|
||||
const dialogRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Save the element that triggered the dialog so we can restore focus on close
|
||||
const triggerRef = useRef<HTMLElement | null>(null);
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
triggerRef.current = document.activeElement as HTMLElement | null;
|
||||
} else if (triggerRef.current !== null) {
|
||||
triggerRef.current.focus();
|
||||
triggerRef.current = null;
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
// Close on Escape key
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") onClose();
|
||||
};
|
||||
window.addEventListener("keydown", handler);
|
||||
return () => window.removeEventListener("keydown", handler);
|
||||
}, [open, onClose]);
|
||||
|
||||
// Auto-focus first focusable element when dialog opens
|
||||
useEffect(() => {
|
||||
if (!open || dialogRef.current === null) return;
|
||||
const focusable = Array.from(
|
||||
dialogRef.current.querySelectorAll<HTMLElement>(
|
||||
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])',
|
||||
),
|
||||
).filter(
|
||||
(el) =>
|
||||
el.hidden === false &&
|
||||
!(el as HTMLButtonElement).disabled &&
|
||||
el.offsetParent !== null &&
|
||||
window.getComputedStyle(el).display !== "none",
|
||||
);
|
||||
// Focus the first input/textarea if available, otherwise the close button
|
||||
const firstInput = focusable.find(
|
||||
(el) => el.tagName === "INPUT" || el.tagName === "TEXTAREA" || el.tagName === "SELECT",
|
||||
);
|
||||
(firstInput ?? focusable[0])?.focus();
|
||||
}, [open]);
|
||||
|
||||
// Focus trap — keep Tab within the dialog
|
||||
useEffect(() => {
|
||||
if (!open || dialogRef.current === null) return;
|
||||
const dialog = dialogRef.current;
|
||||
|
||||
const handleTab = (e: KeyboardEvent) => {
|
||||
if (e.key !== "Tab") return;
|
||||
const focusable = Array.from(
|
||||
dialog.querySelectorAll<HTMLElement>(
|
||||
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])',
|
||||
),
|
||||
).filter(
|
||||
(el) =>
|
||||
el.hidden === false &&
|
||||
!(el as HTMLButtonElement).disabled &&
|
||||
el.offsetParent !== null &&
|
||||
window.getComputedStyle(el).display !== "none",
|
||||
);
|
||||
if (focusable.length === 0) return;
|
||||
|
||||
const first = focusable[0];
|
||||
const last = focusable[focusable.length - 1];
|
||||
|
||||
if (e.shiftKey && document.activeElement === first) {
|
||||
e.preventDefault();
|
||||
last.focus();
|
||||
} else if (!e.shiftKey && document.activeElement === last) {
|
||||
e.preventDefault();
|
||||
first.focus();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", handleTab);
|
||||
return () => window.removeEventListener("keydown", handleTab);
|
||||
}, [open]);
|
||||
|
||||
const handleBackdrop = useCallback(
|
||||
(e: MouseEvent) => {
|
||||
if (e.target === e.currentTarget) onClose();
|
||||
},
|
||||
[onClose],
|
||||
);
|
||||
const titleId = `dialog-title-${title.toLowerCase().replace(/[^a-z0-9]+/g, "-")}`;
|
||||
const handleBackdrop = (event: MouseEvent<HTMLDivElement>) => {
|
||||
if (event.target === event.currentTarget) onClose();
|
||||
};
|
||||
const handleKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
|
||||
if (event.key === "Escape") onClose();
|
||||
};
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
tabIndex={-1}
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm"
|
||||
onClick={handleBackdrop}
|
||||
onKeyDown={handleKeyDown}
|
||||
>
|
||||
<div
|
||||
ref={dialogRef}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby={titleId}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useRef, useEffect, type TextareaHTMLAttributes } from "react";
|
||||
import type { CSSProperties, TextareaHTMLAttributes } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface AutoTextareaProps
|
||||
|
|
@ -14,31 +14,21 @@ export function AutoTextarea({
|
|||
maxRows = 6,
|
||||
className,
|
||||
value,
|
||||
style,
|
||||
...props
|
||||
}: AutoTextareaProps) {
|
||||
const ref = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const el = ref.current;
|
||||
if (el === null) return;
|
||||
|
||||
// Reset height so scrollHeight is recalculated
|
||||
el.style.height = "auto";
|
||||
|
||||
// Compute line height from computed styles
|
||||
const style = window.getComputedStyle(el);
|
||||
const lineHeight = parseFloat(style.lineHeight) || 20;
|
||||
const maxHeight = lineHeight * maxRows;
|
||||
|
||||
el.style.height = `${Math.min(el.scrollHeight, maxHeight)}px`;
|
||||
}, [value, maxRows]);
|
||||
const textareaStyle: CSSProperties & { fieldSizing?: "content" } = {
|
||||
...style,
|
||||
fieldSizing: "content",
|
||||
maxHeight: `calc(${maxRows}lh + 1.5rem)`,
|
||||
};
|
||||
|
||||
return (
|
||||
<textarea
|
||||
ref={ref}
|
||||
value={value}
|
||||
style={textareaStyle}
|
||||
className={cn(
|
||||
"w-full resize-none rounded-lg border border-border bg-surface-100 px-4 py-3 text-sm text-fg placeholder:text-fg-subtle focus:border-brand-500 focus:outline-none focus:ring-1 focus:ring-brand-500",
|
||||
"w-full resize-none overflow-y-auto rounded-lg border border-border bg-surface-100 px-4 py-3 text-sm text-fg placeholder:text-fg-subtle focus:border-brand-500 focus:outline-none focus:ring-1 focus:ring-brand-500",
|
||||
className,
|
||||
)}
|
||||
rows={1}
|
||||
|
|
|
|||
|
|
@ -1,284 +0,0 @@
|
|||
import { useCallback, useRef } from "react";
|
||||
import { useSocket } from "@/providers/socket-provider";
|
||||
import {
|
||||
useConversation,
|
||||
nextMessageId,
|
||||
type ChatMessage,
|
||||
} from "./use-conversation";
|
||||
import { useSessionStore } from "./use-session-store";
|
||||
import { useProgressStore } from "./use-progress-store";
|
||||
import { useSettings } from "@/providers/settings-provider";
|
||||
import type { StreamingMetadata, ExplainEvent } from "@trustgraph/client";
|
||||
|
||||
function metadataFrom(metadata: StreamingMetadata | undefined): ChatMessage["metadata"] | undefined {
|
||||
if (metadata === undefined) return undefined;
|
||||
|
||||
const result: NonNullable<ChatMessage["metadata"]> = {};
|
||||
if (metadata.model !== undefined) result.model = metadata.model;
|
||||
if (metadata.in_token !== undefined) result.inTokens = metadata.in_token;
|
||||
if (metadata.out_token !== undefined) result.outTokens = metadata.out_token;
|
||||
|
||||
return Object.keys(result).length > 0 ? result : undefined;
|
||||
}
|
||||
|
||||
function withoutActivePhase(message: ChatMessage): ChatMessage {
|
||||
const next = { ...message };
|
||||
delete next.activePhase;
|
||||
return next;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Hook
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface UseChatReturn {
|
||||
submitMessage: (opts: { input: string }) => void;
|
||||
cancelRequest: () => void;
|
||||
regenerateLastMessage: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Orchestrates sending a chat message through the selected RAG / agent
|
||||
* pipeline and accumulates streamed chunks into the conversation store.
|
||||
*/
|
||||
export function useChat(): UseChatReturn {
|
||||
const socket = useSocket();
|
||||
const flowId = useSessionStore((s) => s.flowId);
|
||||
const chatMode = useConversation((s) => s.chatMode);
|
||||
const addMessage = useConversation((s) => s.addMessage);
|
||||
const updateLastMessage = useConversation((s) => s.updateLastMessage);
|
||||
const setInput = useConversation((s) => s.setInput);
|
||||
const collection = useSettings((s) => s.settings.collection);
|
||||
const addActivity = useProgressStore((s) => s.addActivity);
|
||||
const removeActivity = useProgressStore((s) => s.removeActivity);
|
||||
|
||||
const abortControllerRef = useRef<AbortController | null>(null);
|
||||
|
||||
const cancelRequest = useCallback(() => {
|
||||
if (abortControllerRef.current !== null) {
|
||||
abortControllerRef.current.abort();
|
||||
abortControllerRef.current = null;
|
||||
}
|
||||
updateLastMessage((prev) =>
|
||||
withoutActivePhase({
|
||||
...prev,
|
||||
content: prev.content.length > 0 ? prev.content : "(Cancelled)",
|
||||
isStreaming: false,
|
||||
}),
|
||||
);
|
||||
removeActivity("Chat request");
|
||||
}, [updateLastMessage, removeActivity]);
|
||||
|
||||
const submitMessage = useCallback(
|
||||
({ input }: { input: string }) => {
|
||||
if (input.trim().length === 0) return;
|
||||
|
||||
// Abort any in-flight request
|
||||
if (abortControllerRef.current !== null) {
|
||||
abortControllerRef.current.abort();
|
||||
}
|
||||
abortControllerRef.current = new AbortController();
|
||||
|
||||
const activityLabel = "Chat request";
|
||||
|
||||
// 1. Add the user message
|
||||
const userMsg: ChatMessage = {
|
||||
id: nextMessageId(),
|
||||
role: "user",
|
||||
content: input,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
addMessage(userMsg);
|
||||
setInput("");
|
||||
|
||||
// 2. Add a placeholder assistant message for streaming
|
||||
const assistantId = nextMessageId();
|
||||
const isAgent = chatMode === "agent";
|
||||
const assistantMsg: ChatMessage = {
|
||||
id: assistantId,
|
||||
role: "assistant",
|
||||
content: "",
|
||||
timestamp: Date.now(),
|
||||
isStreaming: true,
|
||||
...(isAgent
|
||||
? {
|
||||
agentPhases: { think: "", observe: "", answer: "" },
|
||||
activePhase: "think" as const,
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
addMessage(assistantMsg);
|
||||
addActivity(activityLabel);
|
||||
|
||||
const flow = socket.flow(flowId);
|
||||
|
||||
// Collect explainability events during streaming
|
||||
const explainEvents: ExplainEvent[] = [];
|
||||
const onExplain = (event: ExplainEvent) => {
|
||||
explainEvents.push(event);
|
||||
};
|
||||
|
||||
// Attach collected explain events to the message on completion
|
||||
const attachExplainEvents = () => {
|
||||
if (explainEvents.length > 0) {
|
||||
updateLastMessage((prev) => ({
|
||||
...prev,
|
||||
explainEvents: [...explainEvents],
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
// Shared handler for streaming responses (graph-rag / document-rag)
|
||||
const onChunk = (
|
||||
chunk: string,
|
||||
complete: boolean,
|
||||
metadata?: StreamingMetadata,
|
||||
) => {
|
||||
updateLastMessage((prev) => {
|
||||
const next: ChatMessage = {
|
||||
...prev,
|
||||
content: prev.content + chunk,
|
||||
isStreaming: !complete,
|
||||
};
|
||||
const finalMetadata = complete ? metadataFrom(metadata) : undefined;
|
||||
return finalMetadata !== undefined
|
||||
? { ...next, metadata: finalMetadata }
|
||||
: next;
|
||||
});
|
||||
|
||||
if (complete) {
|
||||
attachExplainEvents();
|
||||
removeActivity(activityLabel);
|
||||
}
|
||||
};
|
||||
|
||||
const onError = (error: string) => {
|
||||
updateLastMessage((prev) =>
|
||||
withoutActivePhase({
|
||||
...prev,
|
||||
content: prev.content.length > 0 ? prev.content : `Error: ${error}`,
|
||||
isStreaming: false,
|
||||
}),
|
||||
);
|
||||
removeActivity(activityLabel);
|
||||
};
|
||||
|
||||
// 3. Dispatch based on chat mode
|
||||
switch (chatMode) {
|
||||
case "graph-rag":
|
||||
flow.graphRagStreaming(input, onChunk, onError, undefined, collection, onExplain);
|
||||
break;
|
||||
|
||||
case "document-rag":
|
||||
flow.documentRagStreaming(input, onChunk, onError, undefined, collection, onExplain);
|
||||
break;
|
||||
|
||||
case "agent": {
|
||||
// Agent has separate think / observe / answer streams.
|
||||
// We track each phase in agentPhases and display the answer
|
||||
// as the main content.
|
||||
|
||||
flow.agent(
|
||||
input,
|
||||
// think
|
||||
(chunk, complete) => {
|
||||
updateLastMessage((prev) => {
|
||||
const phases = prev.agentPhases ?? {
|
||||
think: "",
|
||||
observe: "",
|
||||
answer: "",
|
||||
};
|
||||
return {
|
||||
...prev,
|
||||
agentPhases: {
|
||||
...phases,
|
||||
think: phases.think + chunk,
|
||||
},
|
||||
...(complete ? {} : { activePhase: "think" as const }),
|
||||
};
|
||||
});
|
||||
},
|
||||
// observe
|
||||
(chunk, complete) => {
|
||||
updateLastMessage((prev) => {
|
||||
const phases = prev.agentPhases ?? {
|
||||
think: "",
|
||||
observe: "",
|
||||
answer: "",
|
||||
};
|
||||
return {
|
||||
...prev,
|
||||
agentPhases: {
|
||||
...phases,
|
||||
observe: phases.observe + chunk,
|
||||
},
|
||||
...(complete ? {} : { activePhase: "observe" as const }),
|
||||
};
|
||||
});
|
||||
},
|
||||
// answer
|
||||
(chunk, complete, metadata) => {
|
||||
updateLastMessage((prev) => {
|
||||
const phases = prev.agentPhases ?? {
|
||||
think: "",
|
||||
observe: "",
|
||||
answer: "",
|
||||
};
|
||||
const newAnswer = phases.answer + chunk;
|
||||
const next: ChatMessage = {
|
||||
...prev,
|
||||
content: newAnswer,
|
||||
agentPhases: {
|
||||
...phases,
|
||||
answer: newAnswer,
|
||||
},
|
||||
...(complete ? {} : { activePhase: "answer" as const }),
|
||||
isStreaming: !complete,
|
||||
};
|
||||
const finalMetadata = complete ? metadataFrom(metadata) : undefined;
|
||||
const withMetadata = finalMetadata !== undefined
|
||||
? { ...next, metadata: finalMetadata }
|
||||
: next;
|
||||
return complete ? withoutActivePhase(withMetadata) : withMetadata;
|
||||
});
|
||||
if (complete) {
|
||||
attachExplainEvents();
|
||||
removeActivity(activityLabel);
|
||||
}
|
||||
},
|
||||
// error
|
||||
onError,
|
||||
// explainability
|
||||
onExplain,
|
||||
// collection
|
||||
collection,
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
},
|
||||
[
|
||||
socket,
|
||||
flowId,
|
||||
chatMode,
|
||||
collection,
|
||||
addMessage,
|
||||
updateLastMessage,
|
||||
setInput,
|
||||
addActivity,
|
||||
removeActivity,
|
||||
],
|
||||
);
|
||||
|
||||
const regenerateLastMessage = useCallback(() => {
|
||||
const msgs = useConversation.getState().messages;
|
||||
const lastAssistant = [...msgs].reverse().find((m) => m.role === "assistant");
|
||||
const lastUser = [...msgs].reverse().find((m) => m.role === "user");
|
||||
if (lastAssistant !== undefined && lastUser !== undefined) {
|
||||
useConversation.getState().deleteMessage(lastAssistant.id);
|
||||
submitMessage({ input: lastUser.content });
|
||||
}
|
||||
}, [submitMessage]);
|
||||
|
||||
return { submitMessage, cancelRequest, regenerateLastMessage };
|
||||
}
|
||||
|
|
@ -1,118 +0,0 @@
|
|||
import { create } from "zustand";
|
||||
import { persist } from "zustand/middleware";
|
||||
import type { ExplainEvent } from "@trustgraph/client";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type ChatMode = "graph-rag" | "document-rag" | "agent";
|
||||
|
||||
export type MessageRole = "user" | "assistant" | "system";
|
||||
|
||||
/** Phase labels for agent-mode messages */
|
||||
export type AgentPhase = "think" | "observe" | "answer";
|
||||
|
||||
export interface ChatMessage {
|
||||
id: string;
|
||||
role: MessageRole;
|
||||
content: string;
|
||||
/** Timestamp (epoch ms) */
|
||||
timestamp: number;
|
||||
/** If true the message is still being streamed */
|
||||
isStreaming?: boolean;
|
||||
/** Optional metadata attached on completion */
|
||||
metadata?: {
|
||||
model?: string;
|
||||
inTokens?: number;
|
||||
outTokens?: number;
|
||||
};
|
||||
/** Agent-mode phases with their accumulated content */
|
||||
agentPhases?: {
|
||||
think: string;
|
||||
observe: string;
|
||||
answer: string;
|
||||
};
|
||||
/** Indicates the current active phase during streaming */
|
||||
activePhase?: AgentPhase;
|
||||
/** Explainability events received during streaming (graph URIs for source subgraphs) */
|
||||
explainEvents?: ExplainEvent[];
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Store
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface ConversationState {
|
||||
messages: ChatMessage[];
|
||||
input: string;
|
||||
chatMode: ChatMode;
|
||||
|
||||
setInput: (value: string) => void;
|
||||
setChatMode: (mode: ChatMode) => void;
|
||||
|
||||
addMessage: (message: ChatMessage) => void;
|
||||
|
||||
/**
|
||||
* Update the last message in the list (used during streaming to append
|
||||
* chunks). The `updater` receives the current last message and must
|
||||
* return the replacement.
|
||||
*/
|
||||
updateLastMessage: (
|
||||
updater: (prev: ChatMessage) => ChatMessage,
|
||||
) => void;
|
||||
|
||||
deleteMessage: (id: string) => void;
|
||||
|
||||
clearMessages: () => void;
|
||||
}
|
||||
|
||||
let _nextMsgId = 0;
|
||||
export function nextMessageId(): string {
|
||||
return `msg-${++_nextMsgId}-${Date.now()}`;
|
||||
}
|
||||
|
||||
export const useConversation = create<ConversationState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
messages: [] as ChatMessage[],
|
||||
input: "",
|
||||
chatMode: "graph-rag" as ChatMode,
|
||||
|
||||
setInput: (value) => set({ input: value }),
|
||||
setChatMode: (mode) => set({ chatMode: mode }),
|
||||
|
||||
addMessage: (message) =>
|
||||
set((state) => ({ messages: [...state.messages, message] })),
|
||||
|
||||
updateLastMessage: (updater) =>
|
||||
set((state) => {
|
||||
if (state.messages.length === 0) return state;
|
||||
const last = state.messages[state.messages.length - 1]!;
|
||||
const updated = updater(last);
|
||||
return {
|
||||
messages: [...state.messages.slice(0, -1), updated],
|
||||
};
|
||||
}),
|
||||
|
||||
deleteMessage: (id) =>
|
||||
set((state) => ({
|
||||
messages: state.messages.filter((m) => m.id !== id),
|
||||
})),
|
||||
|
||||
clearMessages: () => set({ messages: [] }),
|
||||
}),
|
||||
{
|
||||
name: "tg-conversation",
|
||||
// Only persist messages and chatMode, not input or transient state
|
||||
partialize: (state) => {
|
||||
const MAX_PERSISTED_MESSAGES = 200;
|
||||
const filtered = state.messages.filter((m) => m.isStreaming !== true);
|
||||
return {
|
||||
messages: filtered.slice(-MAX_PERSISTED_MESSAGES),
|
||||
chatMode: state.chatMode,
|
||||
};
|
||||
},
|
||||
},
|
||||
),
|
||||
);
|
||||
|
|
@ -1,130 +0,0 @@
|
|||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useSocket } from "@/providers/socket-provider";
|
||||
import { useConnectionState } from "@/providers/socket-provider";
|
||||
import { useProgressStore } from "./use-progress-store";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface FlowSummary {
|
||||
id: string;
|
||||
description?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface UseFlowsReturn {
|
||||
flows: FlowSummary[];
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
|
||||
/** Refresh the flow list from the server */
|
||||
getFlows: () => Promise<void>;
|
||||
/** Start a new flow */
|
||||
startFlow: (
|
||||
id: string,
|
||||
blueprintName: string,
|
||||
description: string,
|
||||
parameters?: Record<string, unknown>,
|
||||
) => Promise<void>;
|
||||
/** Stop a running flow */
|
||||
stopFlow: (id: string) => Promise<void>;
|
||||
/** Fetch a single flow definition */
|
||||
getFlow: (id: string) => Promise<FlowSummary>;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Hook
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function useFlows(): UseFlowsReturn {
|
||||
const socket = useSocket();
|
||||
const connectionState = useConnectionState();
|
||||
const addActivity = useProgressStore((s) => s.addActivity);
|
||||
const removeActivity = useProgressStore((s) => s.removeActivity);
|
||||
|
||||
const [flows, setFlows] = useState<FlowSummary[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const getFlows = useCallback(async () => {
|
||||
const act = "Load flows";
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
addActivity(act);
|
||||
|
||||
const ids: string[] = await socket.flows().getFlows();
|
||||
const results = await Promise.all(
|
||||
ids.map(async (id) => {
|
||||
const def = await socket.flows().getFlow(id);
|
||||
return { id, ...def } as FlowSummary;
|
||||
}),
|
||||
);
|
||||
|
||||
setFlows(results);
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
setError(msg);
|
||||
console.error("useFlows.getFlows error:", err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
removeActivity(act);
|
||||
}
|
||||
}, [socket, addActivity, removeActivity]);
|
||||
|
||||
const startFlow = useCallback(
|
||||
async (
|
||||
id: string,
|
||||
blueprintName: string,
|
||||
description: string,
|
||||
parameters?: Record<string, unknown>,
|
||||
) => {
|
||||
const act = `Start flow ${id}`;
|
||||
try {
|
||||
addActivity(act);
|
||||
await socket.flows().startFlow(id, blueprintName, description, parameters);
|
||||
// Refresh list after starting
|
||||
await getFlows();
|
||||
} finally {
|
||||
removeActivity(act);
|
||||
}
|
||||
},
|
||||
[socket, addActivity, removeActivity, getFlows],
|
||||
);
|
||||
|
||||
const stopFlow = useCallback(
|
||||
async (id: string) => {
|
||||
const act = `Stop flow ${id}`;
|
||||
try {
|
||||
addActivity(act);
|
||||
await socket.flows().stopFlow(id);
|
||||
await getFlows();
|
||||
} finally {
|
||||
removeActivity(act);
|
||||
}
|
||||
},
|
||||
[socket, addActivity, removeActivity, getFlows],
|
||||
);
|
||||
|
||||
const getFlow = useCallback(
|
||||
async (id: string): Promise<FlowSummary> => {
|
||||
const def = await socket.flows().getFlow(id);
|
||||
return { id, ...def } as FlowSummary;
|
||||
},
|
||||
[socket],
|
||||
);
|
||||
|
||||
// Auto-load flows when the connection becomes ready
|
||||
useEffect(() => {
|
||||
if (
|
||||
connectionState.status === "connected" ||
|
||||
connectionState.status === "authenticated" ||
|
||||
connectionState.status === "unauthenticated"
|
||||
) {
|
||||
getFlows();
|
||||
}
|
||||
}, [connectionState.status, getFlows]);
|
||||
|
||||
return { flows, loading, error, getFlows, startFlow, stopFlow, getFlow };
|
||||
}
|
||||
|
|
@ -1,247 +0,0 @@
|
|||
import { useCallback, useState } from "react";
|
||||
import { useSocket } from "@/providers/socket-provider";
|
||||
import { useSettings } from "@/providers/settings-provider";
|
||||
import { useProgressStore } from "./use-progress-store";
|
||||
import type { DocumentMetadata } from "@trustgraph/client";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface ProcessingMetadata {
|
||||
id: string;
|
||||
"document-id": string;
|
||||
flow: string;
|
||||
collection: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface UploadProgress {
|
||||
phase: "preparing" | "uploading" | "finalizing";
|
||||
chunksTotal: number;
|
||||
chunksUploaded: number;
|
||||
bytesTotal: number;
|
||||
bytesUploaded: number;
|
||||
}
|
||||
|
||||
export interface UseLibraryReturn {
|
||||
documents: DocumentMetadata[];
|
||||
processing: ProcessingMetadata[];
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
|
||||
/** Refresh the documents list */
|
||||
getDocuments: () => Promise<void>;
|
||||
/** Upload a new document (auto-selects simple vs chunked based on size) */
|
||||
uploadDocument: (
|
||||
document: string,
|
||||
mimeType: string,
|
||||
title: string,
|
||||
comments: string,
|
||||
tags: string[],
|
||||
id?: string,
|
||||
) => Promise<void>;
|
||||
/** Upload a large document using chunked upload with progress tracking */
|
||||
uploadDocumentChunked: (
|
||||
base64Content: string,
|
||||
mimeType: string,
|
||||
title: string,
|
||||
comments: string,
|
||||
tags: string[],
|
||||
onProgress?: (progress: UploadProgress) => void,
|
||||
) => Promise<void>;
|
||||
/** Remove a document */
|
||||
removeDocument: (id: string, collection?: string) => Promise<void>;
|
||||
/** Get the list of currently-processing documents */
|
||||
getProcessing: () => Promise<void>;
|
||||
/** Fetch full metadata for a single document */
|
||||
getDocumentMetadata: (documentId: string) => Promise<DocumentMetadata | null>;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Hook
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function useLibrary(): UseLibraryReturn {
|
||||
const socket = useSocket();
|
||||
const user = useSettings((s) => s.settings.user);
|
||||
const addActivity = useProgressStore((s) => s.addActivity);
|
||||
const removeActivity = useProgressStore((s) => s.removeActivity);
|
||||
|
||||
const [documents, setDocuments] = useState<DocumentMetadata[]>([]);
|
||||
const [processing, setProcessing] = useState<ProcessingMetadata[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const getDocuments = useCallback(async () => {
|
||||
const act = "Load documents";
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
addActivity(act);
|
||||
const docs = await socket.librarian().getDocuments();
|
||||
setDocuments(docs);
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
setError(msg);
|
||||
console.error("useLibrary.getDocuments error:", err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
removeActivity(act);
|
||||
}
|
||||
}, [socket, addActivity, removeActivity]);
|
||||
|
||||
const uploadDocument = useCallback(
|
||||
async (
|
||||
document: string,
|
||||
mimeType: string,
|
||||
title: string,
|
||||
comments: string,
|
||||
tags: string[],
|
||||
id?: string,
|
||||
) => {
|
||||
const act = "Upload document";
|
||||
try {
|
||||
addActivity(act);
|
||||
await socket
|
||||
.librarian()
|
||||
.loadDocument(document, mimeType, title, comments, tags, id);
|
||||
// Refresh list after upload
|
||||
await getDocuments();
|
||||
} finally {
|
||||
removeActivity(act);
|
||||
}
|
||||
},
|
||||
[socket, addActivity, removeActivity, getDocuments],
|
||||
);
|
||||
|
||||
const removeDocument = useCallback(
|
||||
async (id: string, collection?: string) => {
|
||||
const act = "Remove document";
|
||||
try {
|
||||
addActivity(act);
|
||||
await socket.librarian().removeDocument(id, collection);
|
||||
await getDocuments();
|
||||
} finally {
|
||||
removeActivity(act);
|
||||
}
|
||||
},
|
||||
[socket, addActivity, removeActivity, getDocuments],
|
||||
);
|
||||
|
||||
const uploadDocumentChunked = useCallback(
|
||||
async (
|
||||
base64Content: string,
|
||||
mimeType: string,
|
||||
title: string,
|
||||
comments: string,
|
||||
tags: string[],
|
||||
onProgress?: (progress: UploadProgress) => void,
|
||||
) => {
|
||||
const act = "Upload document (chunked)";
|
||||
try {
|
||||
addActivity(act);
|
||||
const lib = socket.librarian();
|
||||
const totalSize = base64Content.length;
|
||||
|
||||
onProgress?.({
|
||||
phase: "preparing",
|
||||
chunksTotal: 0,
|
||||
chunksUploaded: 0,
|
||||
bytesTotal: totalSize,
|
||||
bytesUploaded: 0,
|
||||
});
|
||||
|
||||
// Begin the upload session
|
||||
const beginResp = await lib.beginUpload(
|
||||
{
|
||||
id: crypto.randomUUID(),
|
||||
time: Math.floor(Date.now() / 1000),
|
||||
kind: mimeType,
|
||||
title,
|
||||
comments,
|
||||
tags,
|
||||
user,
|
||||
},
|
||||
totalSize,
|
||||
);
|
||||
|
||||
const uploadId = beginResp["upload-id"];
|
||||
const chunkSize = beginResp["chunk-size"];
|
||||
const totalChunks = beginResp["total-chunks"];
|
||||
|
||||
// Upload chunks sequentially
|
||||
let bytesUploaded = 0;
|
||||
for (let i = 0; i < totalChunks; i++) {
|
||||
const start = i * chunkSize;
|
||||
const end = Math.min(start + chunkSize, totalSize);
|
||||
const chunk = base64Content.slice(start, end);
|
||||
|
||||
await lib.uploadChunk(uploadId, i, chunk);
|
||||
bytesUploaded += chunk.length;
|
||||
|
||||
onProgress?.({
|
||||
phase: "uploading",
|
||||
chunksTotal: totalChunks,
|
||||
chunksUploaded: i + 1,
|
||||
bytesTotal: totalSize,
|
||||
bytesUploaded,
|
||||
});
|
||||
}
|
||||
|
||||
// Finalize
|
||||
onProgress?.({
|
||||
phase: "finalizing",
|
||||
chunksTotal: totalChunks,
|
||||
chunksUploaded: totalChunks,
|
||||
bytesTotal: totalSize,
|
||||
bytesUploaded: totalSize,
|
||||
});
|
||||
|
||||
await lib.completeUpload(uploadId);
|
||||
await getDocuments();
|
||||
} finally {
|
||||
removeActivity(act);
|
||||
}
|
||||
},
|
||||
[socket, addActivity, removeActivity, getDocuments],
|
||||
);
|
||||
|
||||
const getProcessing = useCallback(async () => {
|
||||
const act = "Load processing";
|
||||
try {
|
||||
addActivity(act);
|
||||
const procs = await socket.librarian().getProcessing();
|
||||
setProcessing(procs as ProcessingMetadata[]);
|
||||
} catch (err) {
|
||||
console.error("useLibrary.getProcessing error:", err);
|
||||
} finally {
|
||||
removeActivity(act);
|
||||
}
|
||||
}, [socket, addActivity, removeActivity]);
|
||||
|
||||
const getDocumentMetadata = useCallback(
|
||||
async (documentId: string): Promise<DocumentMetadata | null> => {
|
||||
try {
|
||||
return await socket.librarian().getDocumentMetadata(documentId);
|
||||
} catch (err) {
|
||||
console.error("useLibrary.getDocumentMetadata error:", err);
|
||||
return null;
|
||||
}
|
||||
},
|
||||
[socket],
|
||||
);
|
||||
|
||||
return {
|
||||
documents,
|
||||
processing,
|
||||
loading,
|
||||
error,
|
||||
getDocuments,
|
||||
uploadDocument,
|
||||
uploadDocumentChunked,
|
||||
removeDocument,
|
||||
getProcessing,
|
||||
getDocumentMetadata,
|
||||
};
|
||||
}
|
||||
|
|
@ -1,209 +0,0 @@
|
|||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useSocket } from "@/providers/socket-provider";
|
||||
import { useConnectionState } from "@/providers/socket-provider";
|
||||
import { useProgressStore } from "./use-progress-store";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface McpServerConfig {
|
||||
url: string;
|
||||
"remote-name"?: string;
|
||||
"auth-token"?: string;
|
||||
}
|
||||
|
||||
export interface McpServerEntry {
|
||||
key: string;
|
||||
config: McpServerConfig;
|
||||
}
|
||||
|
||||
export interface ToolArgument {
|
||||
name: string;
|
||||
type: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export interface ToolConfig {
|
||||
type: string;
|
||||
name: string;
|
||||
description: string;
|
||||
"mcp-tool"?: string;
|
||||
group?: string[];
|
||||
arguments?: ToolArgument[];
|
||||
}
|
||||
|
||||
export interface ToolEntry {
|
||||
key: string;
|
||||
config: ToolConfig;
|
||||
}
|
||||
|
||||
export interface UseMcpConfigReturn {
|
||||
servers: McpServerEntry[];
|
||||
tools: ToolEntry[];
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
|
||||
loadServers: () => Promise<void>;
|
||||
saveServer: (key: string, config: McpServerConfig) => Promise<void>;
|
||||
deleteServer: (key: string) => Promise<void>;
|
||||
|
||||
loadTools: () => Promise<void>;
|
||||
saveTool: (key: string, config: ToolConfig) => Promise<void>;
|
||||
deleteTool: (key: string) => Promise<void>;
|
||||
|
||||
refresh: () => Promise<void>;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Hook
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function useMcpConfig(): UseMcpConfigReturn {
|
||||
const socket = useSocket();
|
||||
const connectionState = useConnectionState();
|
||||
const addActivity = useProgressStore((s) => s.addActivity);
|
||||
const removeActivity = useProgressStore((s) => s.removeActivity);
|
||||
|
||||
const [servers, setServers] = useState<McpServerEntry[]>([]);
|
||||
const [tools, setTools] = useState<ToolEntry[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const loadServers = useCallback(async () => {
|
||||
try {
|
||||
const raw = await socket.config().getValues("mcp");
|
||||
const entries: McpServerEntry[] = [];
|
||||
for (const item of raw as { key: string; value: string }[]) {
|
||||
try {
|
||||
entries.push({ key: item.key, config: JSON.parse(item.value) });
|
||||
} catch {
|
||||
console.warn(`[useMcpConfig] Failed to parse MCP server config: ${item.key}`);
|
||||
}
|
||||
}
|
||||
setServers(entries);
|
||||
} catch (err) {
|
||||
console.error("[useMcpConfig] loadServers error:", err);
|
||||
throw err;
|
||||
}
|
||||
}, [socket]);
|
||||
|
||||
const loadTools = useCallback(async () => {
|
||||
try {
|
||||
const raw = await socket.config().getValues("tool");
|
||||
const entries: ToolEntry[] = [];
|
||||
for (const item of raw as { key: string; value: string }[]) {
|
||||
try {
|
||||
entries.push({ key: item.key, config: JSON.parse(item.value) });
|
||||
} catch {
|
||||
console.warn(`[useMcpConfig] Failed to parse tool config: ${item.key}`);
|
||||
}
|
||||
}
|
||||
setTools(entries);
|
||||
} catch (err) {
|
||||
console.error("[useMcpConfig] loadTools error:", err);
|
||||
throw err;
|
||||
}
|
||||
}, [socket]);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
const act = "Load MCP config";
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
addActivity(act);
|
||||
await Promise.all([loadServers(), loadTools()]);
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
setError(msg);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
removeActivity(act);
|
||||
}
|
||||
}, [addActivity, removeActivity, loadServers, loadTools]);
|
||||
|
||||
const saveServer = useCallback(
|
||||
async (key: string, config: McpServerConfig) => {
|
||||
const act = `Save MCP server ${key}`;
|
||||
try {
|
||||
addActivity(act);
|
||||
await socket
|
||||
.config()
|
||||
.putConfig([{ type: "mcp", key, value: JSON.stringify(config) }]);
|
||||
await loadServers();
|
||||
} finally {
|
||||
removeActivity(act);
|
||||
}
|
||||
},
|
||||
[socket, addActivity, removeActivity, loadServers],
|
||||
);
|
||||
|
||||
const deleteServer = useCallback(
|
||||
async (key: string) => {
|
||||
const act = `Delete MCP server ${key}`;
|
||||
try {
|
||||
addActivity(act);
|
||||
await socket.config().deleteConfig({ type: "mcp", key });
|
||||
await loadServers();
|
||||
} finally {
|
||||
removeActivity(act);
|
||||
}
|
||||
},
|
||||
[socket, addActivity, removeActivity, loadServers],
|
||||
);
|
||||
|
||||
const saveTool = useCallback(
|
||||
async (key: string, config: ToolConfig) => {
|
||||
const act = `Save tool ${key}`;
|
||||
try {
|
||||
addActivity(act);
|
||||
await socket
|
||||
.config()
|
||||
.putConfig([{ type: "tool", key, value: JSON.stringify(config) }]);
|
||||
await loadTools();
|
||||
} finally {
|
||||
removeActivity(act);
|
||||
}
|
||||
},
|
||||
[socket, addActivity, removeActivity, loadTools],
|
||||
);
|
||||
|
||||
const deleteTool = useCallback(
|
||||
async (key: string) => {
|
||||
const act = `Delete tool ${key}`;
|
||||
try {
|
||||
addActivity(act);
|
||||
await socket.config().deleteConfig({ type: "tool", key });
|
||||
await loadTools();
|
||||
} finally {
|
||||
removeActivity(act);
|
||||
}
|
||||
},
|
||||
[socket, addActivity, removeActivity, loadTools],
|
||||
);
|
||||
|
||||
// Auto-load when connection becomes ready
|
||||
useEffect(() => {
|
||||
if (
|
||||
connectionState.status === "connected" ||
|
||||
connectionState.status === "authenticated" ||
|
||||
connectionState.status === "unauthenticated"
|
||||
) {
|
||||
refresh();
|
||||
}
|
||||
}, [connectionState.status, refresh]);
|
||||
|
||||
return {
|
||||
servers,
|
||||
tools,
|
||||
loading,
|
||||
error,
|
||||
loadServers,
|
||||
saveServer,
|
||||
deleteServer,
|
||||
loadTools,
|
||||
saveTool,
|
||||
deleteTool,
|
||||
refresh,
|
||||
};
|
||||
}
|
||||
|
|
@ -1,39 +0,0 @@
|
|||
import { create } from "zustand";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface ProgressState {
|
||||
/** Set of currently-running activity labels */
|
||||
activities: Set<string>;
|
||||
|
||||
/** Derived: true when at least one activity is running */
|
||||
isLoading: boolean;
|
||||
|
||||
addActivity: (label: string) => void;
|
||||
removeActivity: (label: string) => void;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Store
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const useProgressStore = create<ProgressState>()((set) => ({
|
||||
activities: new Set<string>(),
|
||||
isLoading: false,
|
||||
|
||||
addActivity: (label) =>
|
||||
set((state) => {
|
||||
const next = new Set(state.activities);
|
||||
next.add(label);
|
||||
return { activities: next, isLoading: next.size > 0 };
|
||||
}),
|
||||
|
||||
removeActivity: (label) =>
|
||||
set((state) => {
|
||||
const next = new Set(state.activities);
|
||||
next.delete(label);
|
||||
return { activities: next, isLoading: next.size > 0 };
|
||||
}),
|
||||
}));
|
||||
|
|
@ -1,54 +0,0 @@
|
|||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useSocket } from "@/providers/socket-provider";
|
||||
import { useConnectionState } from "@/providers/socket-provider";
|
||||
|
||||
export function usePrompts() {
|
||||
const socket = useSocket();
|
||||
const connectionState = useConnectionState();
|
||||
const [prompts, setPrompts] = useState<Array<{ id: string; name?: string; description?: string }>>([]);
|
||||
const [systemPrompt, setSystemPrompt] = useState<string>("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const loadPrompts = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const list = await socket.config().getPrompts();
|
||||
setPrompts(Array.isArray(list) ? list : []);
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
setError(msg);
|
||||
console.error("Failed to load prompts:", err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [socket]);
|
||||
|
||||
const loadSystemPrompt = useCallback(async () => {
|
||||
try {
|
||||
const sp = await socket.config().getSystemPrompt();
|
||||
setSystemPrompt(typeof sp === "string" ? sp : JSON.stringify(sp, null, 2));
|
||||
} catch (err) {
|
||||
console.error("Failed to load system prompt:", err);
|
||||
}
|
||||
}, [socket]);
|
||||
|
||||
const getPrompt = useCallback(async (id: string) => {
|
||||
return socket.config().getPrompt(id);
|
||||
}, [socket]);
|
||||
|
||||
// Auto-load when connected
|
||||
useEffect(() => {
|
||||
const connected =
|
||||
connectionState.status === "connected" ||
|
||||
connectionState.status === "authenticated" ||
|
||||
connectionState.status === "unauthenticated";
|
||||
if (connected) {
|
||||
loadPrompts();
|
||||
loadSystemPrompt();
|
||||
}
|
||||
}, [connectionState.status, loadPrompts, loadSystemPrompt]);
|
||||
|
||||
return { prompts, systemPrompt, loading, error, loadPrompts, loadSystemPrompt, getPrompt };
|
||||
}
|
||||
|
|
@ -1,34 +0,0 @@
|
|||
import { create } from "zustand";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Minimal flow description kept in session state after selection. */
|
||||
export interface FlowInfo {
|
||||
id: string;
|
||||
description?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface SessionState {
|
||||
/** Currently-selected flow id */
|
||||
flowId: string;
|
||||
/** Cached flow definition for the selected flow */
|
||||
flow: FlowInfo | null;
|
||||
|
||||
setFlowId: (id: string) => void;
|
||||
setFlow: (flow: FlowInfo | null) => void;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Store
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const useSessionStore = create<SessionState>()((set) => ({
|
||||
flowId: "default",
|
||||
flow: null,
|
||||
|
||||
setFlowId: (id) => set({ flowId: id }),
|
||||
setFlow: (flow) => set({ flow }),
|
||||
}));
|
||||
|
|
@ -1,37 +1,23 @@
|
|||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { RegistryProvider, useAtomMount } from "@effect/atom-react";
|
||||
import App from "@/App";
|
||||
import { SocketProvider } from "@/providers/socket-provider";
|
||||
import { useSettings } from "@/providers/settings-provider";
|
||||
import { connectionStateAtom, themeClassAtom } from "@/atoms/workbench";
|
||||
import { getWorkbenchQaInitialValues } from "@/qa/initial-values";
|
||||
import "@/index.css";
|
||||
|
||||
const queryClient = new QueryClient();
|
||||
|
||||
/**
|
||||
* AppRoot reads settings from the Zustand store and passes them
|
||||
* into the SocketProvider so the WebSocket connection is configured
|
||||
* before any child component mounts.
|
||||
*/
|
||||
function AppRoot() {
|
||||
const settings = useSettings((s) => s.settings);
|
||||
useAtomMount(themeClassAtom);
|
||||
useAtomMount(connectionStateAtom);
|
||||
|
||||
return (
|
||||
<SocketProvider
|
||||
user={settings.user}
|
||||
{...(settings.apiKey.length > 0 ? { apiKey: settings.apiKey } : {})}
|
||||
{...(settings.gatewayUrl.length > 0 ? { socketUrl: settings.gatewayUrl } : {})}
|
||||
>
|
||||
<App />
|
||||
</SocketProvider>
|
||||
);
|
||||
return <App />;
|
||||
}
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||
<React.StrictMode>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<RegistryProvider defaultIdleTTL={1_000} initialValues={getWorkbenchQaInitialValues()}>
|
||||
<AppRoot />
|
||||
</QueryClientProvider>
|
||||
</RegistryProvider>
|
||||
</React.StrictMode>,
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -1,10 +1,5 @@
|
|||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
type KeyboardEvent,
|
||||
} from "react";
|
||||
import { useAtom, useAtomSet, useAtomValue } from "@effect/atom-react";
|
||||
import type { KeyboardEvent } from "react";
|
||||
import {
|
||||
MessageSquareText,
|
||||
Send,
|
||||
|
|
@ -20,47 +15,49 @@ import {
|
|||
} from "lucide-react";
|
||||
import Markdown from "react-markdown";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useConversation, type ChatMessage } from "@/hooks/use-conversation";
|
||||
import { useChat } from "@/hooks/use-chat";
|
||||
import { useSettings } from "@/providers/settings-provider";
|
||||
import { useProgressStore } from "@/hooks/use-progress-store";
|
||||
import {
|
||||
agentPhaseExpandedAtom,
|
||||
cancelChatAtom,
|
||||
clearMessagesAtom,
|
||||
conversationAtom,
|
||||
deleteMessageAtom,
|
||||
isLoadingAtom,
|
||||
regenerateLastMessageAtom,
|
||||
setChatModeAtom,
|
||||
setConversationInputAtom,
|
||||
settingsAtom,
|
||||
submitMessageAtom,
|
||||
type ChatMessage,
|
||||
} from "@/atoms/workbench";
|
||||
import { AutoTextarea } from "@/components/ui/textarea";
|
||||
import { MessageActions } from "@/components/chat/message-actions";
|
||||
import { ExplainGraph } from "@/components/chat/explain-graph";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const MODES = [
|
||||
{ value: "graph-rag" as const, label: "Graph RAG" },
|
||||
{ value: "document-rag" as const, label: "Doc RAG" },
|
||||
{ value: "agent" as const, label: "Agent" },
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Agent phase section (collapsible)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function AgentPhaseBlock({
|
||||
messageId,
|
||||
phase,
|
||||
icon,
|
||||
label,
|
||||
content,
|
||||
isActive,
|
||||
}: {
|
||||
messageId: string;
|
||||
phase: string;
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
content: string;
|
||||
isActive: boolean;
|
||||
}) {
|
||||
const [manualToggle, setManualToggle] = useState<boolean | null>(null);
|
||||
|
||||
const [expandedMap, setExpandedMap] = useAtom(agentPhaseExpandedAtom);
|
||||
const key = `${messageId}:${phase}`;
|
||||
if (content.length === 0 && !isActive) return null;
|
||||
|
||||
// Auto-expand while actively streaming; user can override
|
||||
const expanded = manualToggle ?? isActive;
|
||||
const expanded = expandedMap[key] ?? isActive;
|
||||
|
||||
const phaseColors: Record<string, string> = {
|
||||
think: "border-amber-500/30 bg-amber-500/5",
|
||||
|
|
@ -75,40 +72,22 @@ function AgentPhaseBlock({
|
|||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-md border",
|
||||
phaseColors[phase] ?? "border-border bg-surface-100",
|
||||
)}
|
||||
>
|
||||
<div className={cn("rounded-md border", phaseColors[phase] ?? "border-border bg-surface-100")}>
|
||||
<button
|
||||
onClick={() => setManualToggle((prev) => !(prev ?? isActive))}
|
||||
onClick={() => setExpandedMap({ ...expandedMap, [key]: !expanded })}
|
||||
aria-expanded={expanded}
|
||||
className="flex w-full items-center gap-2 px-3 py-2 text-left text-xs font-medium text-fg-muted"
|
||||
>
|
||||
{expanded ? (
|
||||
<ChevronDown className="h-3 w-3 shrink-0" />
|
||||
) : (
|
||||
<ChevronRight className="h-3 w-3 shrink-0" />
|
||||
)}
|
||||
{expanded ? <ChevronDown className="h-3 w-3 shrink-0" /> : <ChevronRight className="h-3 w-3 shrink-0" />}
|
||||
{icon}
|
||||
<span
|
||||
className={cn(
|
||||
"rounded px-1.5 py-0.5",
|
||||
badgeColors[phase] ?? "bg-surface-200 text-fg-muted",
|
||||
)}
|
||||
>
|
||||
<span className={cn("rounded px-1.5 py-0.5", badgeColors[phase] ?? "bg-surface-200 text-fg-muted")}>
|
||||
{label}
|
||||
</span>
|
||||
{isActive && (
|
||||
<Loader2 className="ml-auto h-3 w-3 animate-spin text-fg-subtle" />
|
||||
)}
|
||||
{isActive && <Loader2 className="ml-auto h-3 w-3 animate-spin text-fg-subtle" />}
|
||||
</button>
|
||||
{expanded && (content.length > 0 || 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.length > 0 ? content : isActive ? "..." : ""}
|
||||
</p>
|
||||
<p className="whitespace-pre-wrap">{content.length > 0 ? content : isActive ? "..." : ""}</p>
|
||||
{isActive && content.length > 0 && (
|
||||
<span className="mt-1 inline-block h-1.5 w-1.5 animate-pulse rounded-full bg-amber-400" />
|
||||
)}
|
||||
|
|
@ -118,168 +97,146 @@ function AgentPhaseBlock({
|
|||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Single message bubble
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function MessageBubble({ msg, collection }: { msg: ChatMessage; collection: string }) {
|
||||
function MessageBubble({
|
||||
msg,
|
||||
collection,
|
||||
isLastAssistant,
|
||||
}: {
|
||||
msg: ChatMessage;
|
||||
collection: string;
|
||||
isLastAssistant: boolean;
|
||||
}) {
|
||||
const deleteMessage = useAtomSet(deleteMessageAtom);
|
||||
const regenerateLastMessage = useAtomSet(regenerateLastMessageAtom);
|
||||
const isUser = msg.role === "user";
|
||||
const agentPhases = msg.agentPhases;
|
||||
const isError = !isUser && msg.content.startsWith("Error:");
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-lg px-4 py-3 text-sm leading-relaxed",
|
||||
isUser
|
||||
? "ml-auto max-w-[80%] bg-brand-700/30 text-fg"
|
||||
: isError
|
||||
? "mr-auto max-w-[80%] border border-error/30 bg-error/10 text-error"
|
||||
: "mr-auto max-w-[80%] bg-surface-100 text-fg",
|
||||
)}
|
||||
>
|
||||
{/* Agent phase blocks (only for agent messages) */}
|
||||
{agentPhases !== undefined && (
|
||||
<div className="mb-2 space-y-1.5">
|
||||
<AgentPhaseBlock
|
||||
phase="think"
|
||||
icon={<Brain className="h-3 w-3" />}
|
||||
label="Thinking"
|
||||
content={agentPhases.think}
|
||||
isActive={msg.activePhase === "think"}
|
||||
/>
|
||||
<AgentPhaseBlock
|
||||
phase="observe"
|
||||
icon={<Eye className="h-3 w-3" />}
|
||||
label="Observing"
|
||||
content={agentPhases.observe}
|
||||
isActive={msg.activePhase === "observe"}
|
||||
/>
|
||||
{agentPhases.answer.length > 0 && (
|
||||
<div className="flex items-center gap-1.5 px-1 pt-1 text-xs text-emerald-400">
|
||||
<CheckCircle className="h-3 w-3" />
|
||||
<span className="font-medium">Answer</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="group relative">
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-lg px-4 py-3 text-sm leading-relaxed",
|
||||
isUser
|
||||
? "ml-auto max-w-[80%] bg-brand-700/30 text-fg"
|
||||
: isError
|
||||
? "mr-auto max-w-[80%] border border-error/30 bg-error/10 text-error"
|
||||
: "mr-auto max-w-[80%] bg-surface-100 text-fg",
|
||||
)}
|
||||
>
|
||||
{agentPhases !== undefined && (
|
||||
<div className="mb-2 space-y-1.5">
|
||||
<AgentPhaseBlock
|
||||
messageId={msg.id}
|
||||
phase="think"
|
||||
icon={<Brain className="h-3 w-3" />}
|
||||
label="Thinking"
|
||||
content={agentPhases.think}
|
||||
isActive={msg.activePhase === "think"}
|
||||
/>
|
||||
<AgentPhaseBlock
|
||||
messageId={msg.id}
|
||||
phase="observe"
|
||||
icon={<Eye className="h-3 w-3" />}
|
||||
label="Observing"
|
||||
content={agentPhases.observe}
|
||||
isActive={msg.activePhase === "observe"}
|
||||
/>
|
||||
{agentPhases.answer.length > 0 && (
|
||||
<div className="flex items-center gap-1.5 px-1 pt-1 text-xs text-emerald-400">
|
||||
<CheckCircle className="h-3 w-3" />
|
||||
<span className="font-medium">Answer</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Main content (markdown for assistant, plain for user) */}
|
||||
{isUser ? (
|
||||
<p className="whitespace-pre-wrap">{msg.content}</p>
|
||||
) : isError ? (
|
||||
<div className="flex items-start gap-2">
|
||||
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" />
|
||||
{isUser ? (
|
||||
<p className="whitespace-pre-wrap">{msg.content}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="prose prose-sm max-w-none text-fg prose-headings:text-fg prose-strong:text-fg prose-p:my-1 prose-a:text-brand-400 prose-pre:bg-surface-200 prose-pre:text-fg prose-code:text-brand-300">
|
||||
<Markdown>{msg.content.length > 0 ? msg.content : msg.isStreaming === true ? "" : "(empty)"}</Markdown>
|
||||
</div>
|
||||
)}
|
||||
) : isError ? (
|
||||
<div className="flex items-start gap-2">
|
||||
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" />
|
||||
<p className="whitespace-pre-wrap">{msg.content}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="prose prose-sm max-w-none text-fg prose-headings:text-fg prose-strong:text-fg prose-p:my-1 prose-a:text-brand-400 prose-pre:bg-surface-200 prose-pre:text-fg prose-code:text-brand-300">
|
||||
<Markdown>{msg.content.length > 0 ? msg.content : msg.isStreaming === true ? "" : "(empty)"}</Markdown>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Streaming indicator */}
|
||||
{msg.isStreaming === true && (
|
||||
<span className="mt-1 inline-block h-2 w-2 animate-pulse rounded-full bg-brand-400" />
|
||||
)}
|
||||
{msg.isStreaming === true && (
|
||||
<span className="mt-1 inline-block h-2 w-2 animate-pulse rounded-full bg-brand-400" />
|
||||
)}
|
||||
|
||||
{/* Token metadata */}
|
||||
{msg.metadata !== undefined && (
|
||||
<div className="mt-2 flex items-center gap-3 text-[10px] text-fg-subtle">
|
||||
{msg.metadata.model !== undefined && msg.metadata.model.length > 0 && <span>{msg.metadata.model}</span>}
|
||||
{msg.metadata.inTokens != null && (
|
||||
<span>in: {msg.metadata.inTokens}</span>
|
||||
)}
|
||||
{msg.metadata.outTokens != null && (
|
||||
<span>out: {msg.metadata.outTokens}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{msg.metadata !== undefined && (
|
||||
<div className="mt-2 flex items-center gap-3 text-[10px] text-fg-subtle">
|
||||
{msg.metadata.model !== undefined && msg.metadata.model.length > 0 && <span>{msg.metadata.model}</span>}
|
||||
{msg.metadata.inTokens != null && <span>in: {msg.metadata.inTokens}</span>}
|
||||
{msg.metadata.outTokens != null && <span>out: {msg.metadata.outTokens}</span>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Explainability graph */}
|
||||
{!isUser && !isError && msg.isStreaming !== true && msg.explainEvents !== undefined && msg.explainEvents.length > 0 && (
|
||||
<ExplainGraph explainEvents={msg.explainEvents} collection={collection} />
|
||||
{!isUser && !isError && msg.isStreaming !== true && msg.explainEvents !== undefined && msg.explainEvents.length > 0 && (
|
||||
<ExplainGraph explainEvents={msg.explainEvents} collection={collection} />
|
||||
)}
|
||||
</div>
|
||||
{!isUser && (
|
||||
<MessageActions
|
||||
messageId={msg.id}
|
||||
content={msg.content}
|
||||
isLastAssistant={isLastAssistant}
|
||||
onDelete={() => deleteMessage(msg.id)}
|
||||
onRegenerate={() => regenerateLastMessage()}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Chat page
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export default function ChatPage() {
|
||||
const messages = useConversation((s) => s.messages);
|
||||
const input = useConversation((s) => s.input);
|
||||
const chatMode = useConversation((s) => s.chatMode);
|
||||
const setInput = useConversation((s) => s.setInput);
|
||||
const setChatMode = useConversation((s) => s.setChatMode);
|
||||
const clearMessages = useConversation((s) => s.clearMessages);
|
||||
const { submitMessage, cancelRequest, regenerateLastMessage } = useChat();
|
||||
const deleteMessage = useConversation((s) => s.deleteMessage);
|
||||
const collection = useSettings((s) => s.settings.collection);
|
||||
const isLoading = useProgressStore((s) => s.isLoading);
|
||||
const conversation = useAtomValue(conversationAtom);
|
||||
const collection = useAtomValue(settingsAtom).collection;
|
||||
const isLoading = useAtomValue(isLoadingAtom);
|
||||
const setInput = useAtomSet(setConversationInputAtom);
|
||||
const setChatMode = useAtomSet(setChatModeAtom);
|
||||
const clearMessages = useAtomSet(clearMessagesAtom);
|
||||
const submitMessage = useAtomSet(submitMessageAtom);
|
||||
const cancelRequest = useAtomSet(cancelChatAtom);
|
||||
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Elapsed time counter while loading
|
||||
const [elapsed, setElapsed] = useState(0);
|
||||
useEffect(() => {
|
||||
if (!isLoading) {
|
||||
setElapsed(0);
|
||||
return;
|
||||
const handleSubmit = () => {
|
||||
if (conversation.input.trim().length > 0) {
|
||||
submitMessage({ input: conversation.input });
|
||||
}
|
||||
const interval = setInterval(() => setElapsed((e) => e + 1), 1000);
|
||||
return () => clearInterval(interval);
|
||||
}, [isLoading]);
|
||||
};
|
||||
|
||||
// Auto-scroll to bottom when messages change
|
||||
useEffect(() => {
|
||||
scrollRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||
}, [messages]);
|
||||
|
||||
const handleSubmit = useCallback(() => {
|
||||
if (input.trim().length > 0) {
|
||||
submitMessage({ input });
|
||||
const handleKeyDown = (event: KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (event.key === "Enter" && !event.shiftKey) {
|
||||
event.preventDefault();
|
||||
handleSubmit();
|
||||
}
|
||||
}, [input, submitMessage]);
|
||||
};
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSubmit();
|
||||
}
|
||||
},
|
||||
[handleSubmit],
|
||||
);
|
||||
const lastAssistantId = [...conversation.messages].reverse().find((message) => message.role === "assistant")?.id;
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
{/* Header */}
|
||||
<div className="mb-4 flex flex-wrap items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-3">
|
||||
<MessageSquareText className="h-6 w-6 text-brand-400" />
|
||||
<h1 className="text-2xl font-bold text-fg">Chat</h1>
|
||||
<span className="ml-2 rounded bg-surface-200 px-2 py-0.5 text-xs text-fg-muted">
|
||||
{collection}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{/* Mode selector */}
|
||||
<div role="group" aria-label="Chat mode" className="flex rounded-lg border border-border bg-surface-100 p-0.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex rounded-lg border border-border bg-surface-100 p-1">
|
||||
{MODES.map((mode) => (
|
||||
<button
|
||||
type="button"
|
||||
key={mode.value}
|
||||
onClick={() => setChatMode(mode.value)}
|
||||
aria-pressed={chatMode === mode.value}
|
||||
className={cn(
|
||||
"rounded-md px-3 py-1 text-xs font-medium transition-colors",
|
||||
chatMode === mode.value
|
||||
"rounded-md px-3 py-1.5 text-xs font-medium transition-colors",
|
||||
conversation.chatMode === mode.value
|
||||
? "bg-brand-600 text-white"
|
||||
: "text-fg-muted hover:text-fg",
|
||||
: "text-fg-muted hover:bg-surface-200 hover:text-fg",
|
||||
)}
|
||||
>
|
||||
{mode.label}
|
||||
|
|
@ -287,84 +244,68 @@ export default function ChatPage() {
|
|||
))}
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => { cancelRequest(); clearMessages(); }}
|
||||
className="rounded-lg p-2 text-fg-subtle hover:bg-surface-200 hover:text-fg"
|
||||
title="Clear messages"
|
||||
aria-label="Clear messages"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
{conversation.messages.length > 0 && (
|
||||
<button
|
||||
onClick={() => clearMessages(null)}
|
||||
className="rounded-lg border border-border p-2 text-fg-subtle hover:bg-surface-200 hover:text-error"
|
||||
aria-label="Clear conversation"
|
||||
title="Clear conversation"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Messages */}
|
||||
<div className="flex-1 space-y-4 overflow-y-auto pb-4 pt-10">
|
||||
{messages.length === 0 && (
|
||||
<div className="flex flex-col items-center justify-center py-20 text-fg-subtle">
|
||||
<MessageSquareText className="mb-3 h-10 w-10 opacity-30" />
|
||||
<p>Send a message to start a conversation.</p>
|
||||
<p className="mt-1 text-xs">
|
||||
Mode: <span className="text-fg-muted">{MODES.find((m) => m.value === chatMode)?.label ?? chatMode}</span>
|
||||
</p>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto rounded-lg border border-border bg-surface-50 p-4">
|
||||
{conversation.messages.length === 0 ? (
|
||||
<div className="flex h-full flex-col items-center justify-center text-center">
|
||||
<MessageSquareText className="mb-3 h-10 w-10 text-fg-subtle opacity-30" />
|
||||
<p className="text-sm text-fg-subtle">Start a conversation with TrustGraph.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{conversation.messages.map((message) => (
|
||||
<MessageBubble
|
||||
key={message.id}
|
||||
msg={message}
|
||||
collection={collection}
|
||||
isLastAssistant={message.id === lastAssistantId}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{messages.map((msg, idx) => {
|
||||
const isLastAssistant =
|
||||
msg.role === "assistant" &&
|
||||
idx === messages.length - 1;
|
||||
|
||||
return (
|
||||
<div key={msg.id} className="group relative">
|
||||
{msg.isStreaming !== true && (
|
||||
<MessageActions
|
||||
content={msg.content}
|
||||
isLastAssistant={isLastAssistant}
|
||||
onDelete={() => deleteMessage(msg.id)}
|
||||
{...(isLastAssistant ? { onRegenerate: regenerateLastMessage } : {})}
|
||||
/>
|
||||
)}
|
||||
<MessageBubble msg={msg} collection={collection} />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<div ref={scrollRef} />
|
||||
</div>
|
||||
|
||||
{/* Loading indicator */}
|
||||
{isLoading && (
|
||||
<div className="flex items-center gap-2 pb-2 text-xs text-fg-subtle">
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
<span>Processing... {elapsed}s</span>
|
||||
<button
|
||||
onClick={cancelRequest}
|
||||
className="flex items-center gap-1 rounded-lg px-3 py-1 text-xs text-red-400 hover:bg-surface-200"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
Cancel
|
||||
</button>
|
||||
<div className="mt-4 rounded-lg border border-border bg-surface-50 p-3">
|
||||
<div className="flex items-end gap-2">
|
||||
<AutoTextarea
|
||||
value={conversation.input}
|
||||
onChange={(event) => setInput(event.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={`Ask using ${MODES.find((mode) => mode.value === conversation.chatMode)?.label ?? "TrustGraph"}...`}
|
||||
disabled={isLoading}
|
||||
maxRows={8}
|
||||
/>
|
||||
{isLoading ? (
|
||||
<button
|
||||
onClick={() => cancelRequest(null)}
|
||||
className="rounded-lg border border-border p-3 text-fg-muted hover:bg-error/10 hover:text-error"
|
||||
aria-label="Cancel request"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={conversation.input.trim().length === 0}
|
||||
className="rounded-lg bg-brand-600 p-3 text-white hover:bg-brand-500 disabled:opacity-40"
|
||||
aria-label="Send message"
|
||||
>
|
||||
<Send className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Input area */}
|
||||
<div className="flex items-end gap-2 border-t border-border pt-4">
|
||||
<AutoTextarea
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Type your message... (Enter to send, Shift+Enter for new line)"
|
||||
aria-label="Chat message"
|
||||
maxRows={6}
|
||||
/>
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={input.trim().length === 0 || isLoading}
|
||||
aria-label="Send message"
|
||||
className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-brand-600 text-white transition-colors hover:bg-brand-500 disabled:opacity-40"
|
||||
>
|
||||
<Send className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useEffect, useState } from "react";
|
||||
import { useAtom, useAtomRefresh, useAtomSet, useAtomValue } from "@effect/atom-react";
|
||||
import {
|
||||
Workflow,
|
||||
Plus,
|
||||
|
|
@ -7,579 +7,201 @@ import {
|
|||
ChevronDown,
|
||||
ChevronRight,
|
||||
Loader2,
|
||||
AlertTriangle,
|
||||
Info,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useFlows, type FlowSummary } from "@/hooks/use-flows";
|
||||
import { useSocket } from "@/providers/socket-provider";
|
||||
import { useNotification } from "@/providers/notification-provider";
|
||||
import {
|
||||
activeActionAtom,
|
||||
encodeJsonUnknownString,
|
||||
flowBlueprintAtom,
|
||||
flowBlueprintsAtom,
|
||||
flowExpandedAtom,
|
||||
flowsAtom,
|
||||
flowsStartDialogOpenAtom,
|
||||
parseJsonUnknown,
|
||||
resultData,
|
||||
resultError,
|
||||
resultLoading,
|
||||
startFlowAtom,
|
||||
startFlowFormAtom,
|
||||
stopFlowAtom,
|
||||
} from "@/atoms/workbench";
|
||||
import { Dialog } from "@/components/ui/dialog";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Start flow dialog
|
||||
// ---------------------------------------------------------------------------
|
||||
function StartFlowDialog() {
|
||||
const [open, setOpen] = useAtom(flowsStartDialogOpenAtom);
|
||||
const [form, setForm] = useAtom(startFlowFormAtom);
|
||||
const startFlow = useAtomSet(startFlowAtom);
|
||||
const blueprintsResult = useAtomValue(flowBlueprintsAtom);
|
||||
const blueprints = resultData(blueprintsResult, []);
|
||||
const blueprintDetail = resultData(useAtomValue(flowBlueprintAtom(form.blueprint)), null) as Record<string, unknown> | null;
|
||||
const loadingBlueprints = resultLoading(blueprintsResult, blueprints);
|
||||
const isValid = form.id.trim().length > 0 && form.blueprint.length > 0 && form.description.trim().length > 0;
|
||||
|
||||
function StartFlowDialog({
|
||||
open,
|
||||
onClose,
|
||||
onStart,
|
||||
}: {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onStart: (
|
||||
id: string,
|
||||
blueprint: string,
|
||||
description: string,
|
||||
params: Record<string, unknown>,
|
||||
) => Promise<void>;
|
||||
}) {
|
||||
const socket = useSocket();
|
||||
const [blueprints, setBlueprints] = useState<string[]>([]);
|
||||
const [loadingBlueprints, setLoadingBlueprints] = useState(false);
|
||||
const [id, setId] = useState("");
|
||||
const [blueprint, setBlueprint] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [paramsJson, setParamsJson] = useState("{}");
|
||||
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(() => {
|
||||
if (!open) return;
|
||||
setLoadingBlueprints(true);
|
||||
socket
|
||||
.flows()
|
||||
.getFlowBlueprints()
|
||||
.then((names) => {
|
||||
const list = names ?? [];
|
||||
setBlueprints(list);
|
||||
if (list.length > 0 && blueprint.length === 0) {
|
||||
setBlueprint(list[0]!);
|
||||
}
|
||||
})
|
||||
.catch(() => setBlueprints([]))
|
||||
.finally(() => setLoadingBlueprints(false));
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [open, socket]);
|
||||
|
||||
// Fetch blueprint definition when selection changes
|
||||
useEffect(() => {
|
||||
if (blueprint.length === 0) {
|
||||
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 !== undefined && paramsDef !== null && 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 !== null && 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 === false) setBlueprintDef(null);
|
||||
})
|
||||
.finally(() => {
|
||||
if (cancelled === false) setLoadingDef(false);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [blueprint, socket]);
|
||||
|
||||
const reset = () => {
|
||||
setId("");
|
||||
setBlueprint("");
|
||||
setDescription("");
|
||||
setParamsJson("{}");
|
||||
setParamsError(null);
|
||||
setSubmitting(false);
|
||||
setSubmitted(false);
|
||||
setBlueprintDef(null);
|
||||
setLoadingDef(false);
|
||||
setDefExpanded(false);
|
||||
const close = () => {
|
||||
setForm({
|
||||
id: "",
|
||||
blueprint: "",
|
||||
description: "",
|
||||
paramsJson: "{}",
|
||||
submitting: false,
|
||||
paramsError: null,
|
||||
submitted: false,
|
||||
definitionExpanded: false,
|
||||
});
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
setSubmitted(true);
|
||||
if (!isValid) return;
|
||||
|
||||
let params: Record<string, unknown> = {};
|
||||
try {
|
||||
params = JSON.parse(paramsJson);
|
||||
setParamsError(null);
|
||||
} catch {
|
||||
setParamsError("Invalid JSON");
|
||||
return;
|
||||
}
|
||||
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await onStart(id, blueprint, description, params);
|
||||
reset();
|
||||
onClose();
|
||||
} catch {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const isValid = id.trim().length > 0 && blueprint.length > 0 && description.trim().length > 0;
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onClose={() => {
|
||||
if (!submitting) {
|
||||
reset();
|
||||
onClose();
|
||||
}
|
||||
}}
|
||||
onClose={close}
|
||||
title="Start Flow"
|
||||
footer={
|
||||
<>
|
||||
<button
|
||||
onClick={() => {
|
||||
reset();
|
||||
onClose();
|
||||
}}
|
||||
disabled={submitting}
|
||||
onClick={close}
|
||||
disabled={form.submitting}
|
||||
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={handleSubmit}
|
||||
disabled={submitting}
|
||||
onClick={() => {
|
||||
setForm({ ...form, submitted: true });
|
||||
if (!isValid) return;
|
||||
const parameters = parseJsonUnknown(form.paramsJson);
|
||||
if (parameters === undefined || typeof parameters !== "object" || parameters === null || Array.isArray(parameters)) {
|
||||
setForm({ ...form, paramsError: "Invalid JSON", submitted: true });
|
||||
return;
|
||||
}
|
||||
startFlow({
|
||||
id: form.id.trim(),
|
||||
blueprint: form.blueprint,
|
||||
description: form.description.trim(),
|
||||
parameters: parameters as Record<string, unknown>,
|
||||
});
|
||||
close();
|
||||
}}
|
||||
disabled={form.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" />}
|
||||
{form.submitting && <Loader2 className="h-3.5 w-3.5 animate-spin" />}
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
Start
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{/* Flow ID */}
|
||||
<div className="mb-3 space-y-1.5">
|
||||
<label htmlFor="flow-id" className="block text-sm font-medium text-fg-muted">
|
||||
Flow ID <span className="text-error">*</span>
|
||||
<div className="space-y-3">
|
||||
<label className="block">
|
||||
<span className="mb-1 block text-sm font-medium text-fg-muted">Flow ID</span>
|
||||
<input
|
||||
value={form.id}
|
||||
onChange={(event) => setForm({ ...form, id: event.target.value })}
|
||||
placeholder="my-flow-id"
|
||||
className="w-full rounded-lg border border-border bg-surface-100 px-3 py-2 text-sm text-fg placeholder:text-fg-subtle focus:border-brand-500 focus:outline-none focus:ring-1 focus:ring-brand-500"
|
||||
/>
|
||||
{form.submitted && form.id.trim().length === 0 && (
|
||||
<p className="mt-1 text-xs text-red-400">Flow ID is required</p>
|
||||
)}
|
||||
</label>
|
||||
<input
|
||||
id="flow-id"
|
||||
type="text"
|
||||
value={id}
|
||||
onChange={(e) => setId(e.target.value)}
|
||||
placeholder="my-flow-id"
|
||||
className="w-full rounded-lg border border-border bg-surface-100 px-3 py-2 text-sm text-fg placeholder:text-fg-subtle focus:border-brand-500 focus:outline-none focus:ring-1 focus:ring-brand-500"
|
||||
/>
|
||||
{submitted && id.trim().length === 0 && (
|
||||
<p className="mt-1 text-xs text-red-400">Flow ID is required</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Blueprint name */}
|
||||
<div className="mb-3 space-y-1.5">
|
||||
<label htmlFor="flow-blueprint" className="block text-sm font-medium text-fg-muted">
|
||||
Blueprint <span className="text-error">*</span>
|
||||
</label>
|
||||
{loadingBlueprints ? (
|
||||
<div className="flex items-center gap-2 py-2 text-xs text-fg-subtle">
|
||||
<Loader2 className="h-3 w-3 animate-spin" /> Loading blueprints...
|
||||
</div>
|
||||
) : (
|
||||
<select
|
||||
id="flow-blueprint"
|
||||
value={blueprint}
|
||||
onChange={(e) => setBlueprint(e.target.value)}
|
||||
className="w-full rounded-lg border border-border bg-surface-100 px-3 py-2 text-sm text-fg focus:border-brand-500 focus:outline-none focus:ring-1 focus:ring-brand-500"
|
||||
>
|
||||
<option value="" disabled>
|
||||
Select a blueprint
|
||||
</option>
|
||||
{blueprints.map((bp) => (
|
||||
<option key={bp} value={bp}>
|
||||
{bp}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
{submitted && blueprint.length === 0 && (
|
||||
<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 !== null && !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
|
||||
<label className="block">
|
||||
<span className="mb-1 block text-sm font-medium text-fg-muted">Blueprint</span>
|
||||
{loadingBlueprints ? (
|
||||
<div className="flex items-center gap-2 py-2 text-xs text-fg-subtle">
|
||||
<Loader2 className="h-3 w-3 animate-spin" /> Loading blueprints...
|
||||
</div>
|
||||
) : (
|
||||
<select
|
||||
value={form.blueprint}
|
||||
onChange={(event) => setForm({ ...form, blueprint: event.target.value })}
|
||||
className="w-full rounded-lg border border-border bg-surface-100 px-3 py-2 text-sm text-fg focus:border-brand-500 focus:outline-none focus:ring-1 focus:ring-brand-500"
|
||||
>
|
||||
<option value="">Select a blueprint</option>
|
||||
{blueprints.map((name) => (
|
||||
<option key={name} value={name}>
|
||||
{name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
{form.submitted && form.blueprint.length === 0 && (
|
||||
<p className="mt-1 text-xs text-red-400">Blueprint is required</p>
|
||||
)}
|
||||
</label>
|
||||
|
||||
{/* Description from definition */}
|
||||
{(blueprintDef.description !== undefined || blueprintDef.desc !== undefined) && (
|
||||
<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 === undefined || paramsDef === null || 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 !== undefined ? String(s.type) : undefined;
|
||||
const defaultVal = s !== null && "default" in s ? s.default : undefined;
|
||||
const desc = s?.description !== undefined ? 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 !== undefined && (
|
||||
<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 !== undefined && <span className="text-fg-subtle">- {desc}</span>}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* Raw JSON toggle */}
|
||||
{blueprintDetail !== null && (
|
||||
<div className="rounded-lg border border-border bg-surface-50 p-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDefExpanded((p) => !p)}
|
||||
className="mt-2 flex items-center gap-1 text-[11px] text-fg-subtle hover:text-fg-muted"
|
||||
onClick={() => setForm({ ...form, definitionExpanded: !form.definitionExpanded })}
|
||||
className="flex w-full items-center gap-1.5 text-left text-xs font-medium text-fg-muted"
|
||||
>
|
||||
{defExpanded ? (
|
||||
<ChevronDown className="h-3 w-3" />
|
||||
) : (
|
||||
<ChevronRight className="h-3 w-3" />
|
||||
)}
|
||||
Raw definition
|
||||
{form.definitionExpanded ? <ChevronDown className="h-3.5 w-3.5" /> : <ChevronRight className="h-3.5 w-3.5" />}
|
||||
<Info className="h-3.5 w-3.5 text-brand-400" />
|
||||
Blueprint Details
|
||||
</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)}
|
||||
{form.definitionExpanded && (
|
||||
<pre className="mt-2 max-h-48 overflow-auto rounded-md bg-surface-100 p-2 font-mono text-[10px] text-fg-muted">
|
||||
{encodeJsonUnknownString(blueprintDetail)}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<div className="mb-3 space-y-1.5">
|
||||
<label htmlFor="flow-description" className="block text-sm font-medium text-fg-muted">
|
||||
Description <span className="text-error">*</span>
|
||||
</label>
|
||||
<input
|
||||
id="flow-description"
|
||||
type="text"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="Human-readable description"
|
||||
className="w-full rounded-lg border border-border bg-surface-100 px-3 py-2 text-sm text-fg placeholder:text-fg-subtle focus:border-brand-500 focus:outline-none focus:ring-1 focus:ring-brand-500"
|
||||
/>
|
||||
{submitted && description.trim().length === 0 && (
|
||||
<p className="mt-1 text-xs text-red-400">Description is required</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Parameters (JSON) */}
|
||||
<div className="space-y-1.5">
|
||||
<label htmlFor="flow-params" className="block text-sm font-medium text-fg-muted">
|
||||
Parameters (JSON)
|
||||
</label>
|
||||
<textarea
|
||||
id="flow-params"
|
||||
value={paramsJson}
|
||||
onChange={(e) => {
|
||||
setParamsJson(e.target.value);
|
||||
setParamsError(null);
|
||||
}}
|
||||
rows={4}
|
||||
className={cn(
|
||||
"w-full resize-none rounded-lg border bg-surface-100 px-3 py-2 font-mono text-xs text-fg placeholder:text-fg-subtle focus:outline-none focus:ring-1",
|
||||
paramsError !== null
|
||||
? "border-error focus:border-error focus:ring-error"
|
||||
: "border-border focus:border-brand-500 focus:ring-brand-500",
|
||||
<label className="block">
|
||||
<span className="mb-1 block text-sm font-medium text-fg-muted">Description</span>
|
||||
<input
|
||||
value={form.description}
|
||||
onChange={(event) => setForm({ ...form, description: event.target.value })}
|
||||
placeholder="What this flow does"
|
||||
className="w-full rounded-lg border border-border bg-surface-100 px-3 py-2 text-sm text-fg placeholder:text-fg-subtle focus:border-brand-500 focus:outline-none focus:ring-1 focus:ring-brand-500"
|
||||
/>
|
||||
{form.submitted && form.description.trim().length === 0 && (
|
||||
<p className="mt-1 text-xs text-red-400">Description is required</p>
|
||||
)}
|
||||
/>
|
||||
{paramsError !== null && (
|
||||
<p className="text-xs text-error">{paramsError}</p>
|
||||
)}
|
||||
</label>
|
||||
|
||||
<label className="block">
|
||||
<span className="mb-1 block text-sm font-medium text-fg-muted">Parameters JSON</span>
|
||||
<textarea
|
||||
value={form.paramsJson}
|
||||
onChange={(event) => setForm({ ...form, paramsJson: event.target.value, paramsError: null })}
|
||||
rows={6}
|
||||
className="w-full resize-none rounded-lg border border-border bg-surface-100 px-3 py-2 font-mono text-xs text-fg placeholder:text-fg-subtle focus:border-brand-500 focus:outline-none focus:ring-1 focus:ring-brand-500"
|
||||
/>
|
||||
{form.paramsError !== null && <p className="mt-1 text-xs text-red-400">{form.paramsError}</p>}
|
||||
</label>
|
||||
</div>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Stop flow confirm dialog
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function StopFlowDialog({
|
||||
open,
|
||||
flowId,
|
||||
onClose,
|
||||
onConfirm,
|
||||
}: {
|
||||
open: boolean;
|
||||
flowId: string;
|
||||
onClose: () => 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={() => {
|
||||
if (!stopping) onClose();
|
||||
}}
|
||||
title="Stop Flow"
|
||||
footer={
|
||||
<>
|
||||
<button
|
||||
onClick={onClose}
|
||||
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={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>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<AlertTriangle className="mt-0.5 h-5 w-5 shrink-0 text-error" />
|
||||
<p className="text-sm text-fg-muted">
|
||||
Are you sure you want to stop flow{" "}
|
||||
<span className="font-mono font-medium text-fg">{flowId}</span>?
|
||||
</p>
|
||||
</div>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Flow detail row (expandable)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function FlowRow({
|
||||
flow,
|
||||
onStop,
|
||||
}: {
|
||||
flow: FlowSummary;
|
||||
onStop: (id: string) => void;
|
||||
}) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
// Determine all the extra keys beyond id/description
|
||||
const detailKeys = Object.keys(flow).filter(
|
||||
(k) => k !== "id" && k !== "description",
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<tr
|
||||
className="cursor-pointer hover:bg-surface-100/50"
|
||||
onClick={() => setExpanded((p) => !p)}
|
||||
>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
{expanded ? (
|
||||
<ChevronDown className="h-3.5 w-3.5 shrink-0 text-fg-subtle" />
|
||||
) : (
|
||||
<ChevronRight className="h-3.5 w-3.5 shrink-0 text-fg-subtle" />
|
||||
)}
|
||||
<span className="font-mono text-sm text-fg">{flow.id}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-fg-muted">
|
||||
{(flow.description ?? "").length > 0 ? flow.description : "--"}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<Badge variant="success">Running</Badge>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onStop(flow.id);
|
||||
}}
|
||||
className="rounded p-1.5 text-fg-subtle hover:bg-error/10 hover:text-error"
|
||||
title="Stop flow"
|
||||
aria-label={`Stop flow ${flow.id}`}
|
||||
>
|
||||
<Square className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
{/* Detail row */}
|
||||
{expanded && detailKeys.length > 0 && (
|
||||
<tr>
|
||||
<td colSpan={4} className="bg-surface-50 px-8 py-3">
|
||||
<div className="grid grid-cols-2 gap-x-6 gap-y-2 text-xs">
|
||||
{detailKeys.map((key) => (
|
||||
<div key={key}>
|
||||
<span className="font-medium text-fg-muted">{key}: </span>
|
||||
<span className="text-fg-subtle">
|
||||
{typeof flow[key] === "object"
|
||||
? JSON.stringify(flow[key])
|
||||
: String(flow[key] ?? "")}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Flows page
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export default function FlowsPage() {
|
||||
const { flows, loading, error, getFlows, startFlow, stopFlow } = useFlows();
|
||||
const notify = useNotification();
|
||||
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [stopTarget, setStopTarget] = useState<string | null>(null);
|
||||
|
||||
// Auto-refresh every 10 seconds
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
getFlows();
|
||||
}, 10_000);
|
||||
return () => clearInterval(interval);
|
||||
}, [getFlows]);
|
||||
|
||||
// Also refresh on window focus
|
||||
useEffect(() => {
|
||||
const handler = () => getFlows();
|
||||
window.addEventListener("focus", handler);
|
||||
return () => window.removeEventListener("focus", handler);
|
||||
}, [getFlows]);
|
||||
|
||||
const handleStart = async (
|
||||
id: string,
|
||||
blueprint: string,
|
||||
description: string,
|
||||
params: Record<string, unknown>,
|
||||
) => {
|
||||
try {
|
||||
await startFlow(id, blueprint, description, params);
|
||||
notify.success("Flow started", `Flow "${id}" has been started.`);
|
||||
} catch (err) {
|
||||
notify.error(
|
||||
"Failed to start flow",
|
||||
err instanceof Error ? err.message : String(err),
|
||||
);
|
||||
throw err; // re-throw so dialog stays open
|
||||
}
|
||||
};
|
||||
|
||||
const handleStop = async () => {
|
||||
if (stopTarget === null || stopTarget.length === 0) return;
|
||||
try {
|
||||
await stopFlow(stopTarget);
|
||||
notify.success("Flow stopped", `Flow "${stopTarget}" has been stopped.`);
|
||||
} catch (err) {
|
||||
notify.error(
|
||||
"Failed to stop flow",
|
||||
err instanceof Error ? err.message : String(err),
|
||||
);
|
||||
}
|
||||
setStopTarget(null);
|
||||
};
|
||||
const flowsResult = useAtomValue(flowsAtom);
|
||||
const refreshFlows = useAtomRefresh(flowsAtom);
|
||||
const [expanded, setExpanded] = useAtom(flowExpandedAtom);
|
||||
const setStartOpen = useAtomSet(flowsStartDialogOpenAtom);
|
||||
const stopFlow = useAtomSet(stopFlowAtom);
|
||||
const actionInProgress = useAtomValue(activeActionAtom);
|
||||
const flows = resultData(flowsResult, []);
|
||||
const loading = resultLoading(flowsResult, flows);
|
||||
const error = resultError(flowsResult);
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
{/* Header */}
|
||||
<div className="mb-6 flex flex-wrap items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-3">
|
||||
<Workflow className="h-6 w-6 text-brand-400" />
|
||||
<h1 className="text-2xl font-bold text-fg">Flows</h1>
|
||||
<span className="ml-2 rounded bg-surface-200 px-2 py-0.5 text-xs text-fg-muted">
|
||||
{flows.length} active
|
||||
</span>
|
||||
{!loading && <Badge>{flows.length} flow{flows.length !== 1 ? "s" : ""}</Badge>}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => getFlows()}
|
||||
onClick={refreshFlows}
|
||||
disabled={loading}
|
||||
className="flex items-center gap-1.5 rounded-lg border border-border px-3 py-2 text-sm text-fg-muted transition-colors hover:bg-surface-200 disabled:opacity-40"
|
||||
>
|
||||
|
|
@ -587,16 +209,15 @@ export default function FlowsPage() {
|
|||
Refresh
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setCreateOpen(true)}
|
||||
className="flex items-center gap-2 rounded-lg bg-brand-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-brand-500"
|
||||
onClick={() => setStartOpen(true)}
|
||||
className="flex items-center gap-1.5 rounded-lg bg-brand-600 px-3 py-2 text-sm font-medium text-white hover:bg-brand-500"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
Start Flow
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
{loading && flows.length === 0 && (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="mr-2 h-5 w-5 animate-spin text-fg-subtle" />
|
||||
|
|
@ -605,7 +226,7 @@ export default function FlowsPage() {
|
|||
)}
|
||||
|
||||
{error !== null && (
|
||||
<p role="alert" className="mb-4 rounded-lg bg-error/10 px-4 py-2 text-sm text-error">
|
||||
<p className="mb-4 rounded-lg bg-error/10 px-4 py-2 text-sm text-error">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
|
@ -613,50 +234,51 @@ export default function FlowsPage() {
|
|||
{!loading && error === null && flows.length === 0 && (
|
||||
<div className="flex flex-1 flex-col items-center justify-center">
|
||||
<Workflow className="mb-3 h-10 w-10 text-fg-subtle opacity-30" />
|
||||
<p className="text-fg-subtle">No flows configured.</p>
|
||||
<p className="mt-1 text-xs text-fg-subtle">
|
||||
Click "Start Flow" to create one.
|
||||
</p>
|
||||
<p className="text-fg-subtle">No flows are running.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{flows.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">
|
||||
<tr>
|
||||
<th className="px-4 py-3 font-medium">ID</th>
|
||||
<th className="px-4 py-3 font-medium">Description</th>
|
||||
<th className="px-4 py-3 font-medium">Status</th>
|
||||
<th className="px-4 py-3 font-medium text-right">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{flows.map((flow) => (
|
||||
<FlowRow
|
||||
key={flow.id}
|
||||
flow={flow}
|
||||
onStop={(id) => setStopTarget(id)}
|
||||
/>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<div className="space-y-3">
|
||||
{flows.map((flow) => {
|
||||
const isExpanded = expanded[flow.id] === true;
|
||||
return (
|
||||
<div key={flow.id} className="rounded-lg border border-border bg-surface-50">
|
||||
<div className="flex items-center justify-between gap-3 px-4 py-3">
|
||||
<button
|
||||
onClick={() => setExpanded({ ...expanded, [flow.id]: !isExpanded })}
|
||||
className="flex min-w-0 flex-1 items-center gap-2 text-left"
|
||||
>
|
||||
{isExpanded ? <ChevronDown className="h-4 w-4 text-fg-subtle" /> : <ChevronRight className="h-4 w-4 text-fg-subtle" />}
|
||||
<span className="truncate font-mono text-sm font-medium text-fg">{flow.id}</span>
|
||||
{flow.description !== undefined && (
|
||||
<span className="truncate text-xs text-fg-muted">{flow.description}</span>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => stopFlow(flow.id)}
|
||||
disabled={actionInProgress === flow.id}
|
||||
aria-label={`Stop flow ${flow.id}`}
|
||||
className="flex items-center gap-1.5 rounded px-2.5 py-1.5 text-xs font-medium text-error hover:bg-error/10 disabled:opacity-40"
|
||||
>
|
||||
{actionInProgress === flow.id ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Square className="h-3.5 w-3.5" />}
|
||||
Stop
|
||||
</button>
|
||||
</div>
|
||||
{isExpanded && (
|
||||
<div className="border-t border-border px-4 py-3">
|
||||
<pre className="max-h-96 overflow-auto rounded-md bg-surface-100 p-3 font-mono text-xs text-fg-muted">
|
||||
{encodeJsonUnknownString(flow)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Dialogs */}
|
||||
<StartFlowDialog
|
||||
open={createOpen}
|
||||
onClose={() => setCreateOpen(false)}
|
||||
onStart={handleStart}
|
||||
/>
|
||||
|
||||
<StopFlowDialog
|
||||
open={stopTarget !== null}
|
||||
flowId={stopTarget ?? ""}
|
||||
onClose={() => setStopTarget(null)}
|
||||
onConfirm={handleStop}
|
||||
/>
|
||||
<StartFlowDialog />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,59 +1,37 @@
|
|||
import {
|
||||
lazy,
|
||||
Suspense,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { lazy, Suspense } from "react";
|
||||
import { useAtom, useAtomRefresh, useAtomValue } from "@effect/atom-react";
|
||||
import {
|
||||
Rotate3d,
|
||||
Search,
|
||||
ZoomIn,
|
||||
ZoomOut,
|
||||
Maximize,
|
||||
Loader2,
|
||||
X,
|
||||
ArrowRight,
|
||||
ArrowLeft,
|
||||
Filter,
|
||||
RefreshCw,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useSocket } from "@/providers/socket-provider";
|
||||
import { useSessionStore } from "@/hooks/use-session-store";
|
||||
import { useSettings } from "@/providers/settings-provider";
|
||||
import { useProgressStore } from "@/hooks/use-progress-store";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import type { Triple, Term } from "@trustgraph/client";
|
||||
import {
|
||||
termValue,
|
||||
flowIdAtom,
|
||||
graphTriplesAtom,
|
||||
graphViewAtom,
|
||||
resultData,
|
||||
resultError,
|
||||
resultLoading,
|
||||
settingsAtom,
|
||||
} from "@/atoms/workbench";
|
||||
import type { Triple } from "@trustgraph/client";
|
||||
import {
|
||||
localName,
|
||||
hashColor,
|
||||
triplesToGraph,
|
||||
RDFS_LABEL,
|
||||
RDF_TYPE,
|
||||
termValue,
|
||||
type GraphNode,
|
||||
type GraphLink,
|
||||
} from "@/lib/graph-utils";
|
||||
import type { ForceGraphProps } from "react-force-graph-2d";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Lazy-load ForceGraph2D to keep bundle size down
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
import type {
|
||||
ForceGraphMethods,
|
||||
ForceGraphProps,
|
||||
} from "react-force-graph-2d";
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const ForceGraph2D = lazy(() => import("react-force-graph-2d")) as unknown as React.ComponentType<ForceGraphProps<any, any> & { ref?: React.Ref<any> }>;
|
||||
|
||||
// Graph helpers imported from @/lib/graph-utils
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Node detail panel
|
||||
// ---------------------------------------------------------------------------
|
||||
const ForceGraph2D = lazy(() => import("react-force-graph-2d")) as unknown as React.ComponentType<ForceGraphProps<GraphNode, GraphLink>>;
|
||||
|
||||
function NodeDetailPanel({
|
||||
nodeId,
|
||||
|
|
@ -68,672 +46,243 @@ function NodeDetailPanel({
|
|||
labelMap: Map<string, string>;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
// Find triples where this node is subject or object
|
||||
const related = useMemo(() => {
|
||||
const outbound: { predicate: string; object: string; objectLabel: string }[] = [];
|
||||
const inbound: { predicate: string; subject: string; subjectLabel: string }[] = [];
|
||||
const outbound: { predicate: string; object: string; objectLabel: string }[] = [];
|
||||
const inbound: { predicate: string; subject: string; subjectLabel: string }[] = [];
|
||||
|
||||
for (const t of triples) {
|
||||
const sVal = termValue(t.s);
|
||||
const pVal = termValue(t.p);
|
||||
const oVal = termValue(t.o);
|
||||
|
||||
if (pVal === RDFS_LABEL || pVal === RDF_TYPE) continue;
|
||||
|
||||
if (sVal === nodeId) {
|
||||
outbound.push({
|
||||
predicate: labelMap.get(pVal) ?? localName(pVal),
|
||||
object: oVal,
|
||||
objectLabel: labelMap.get(oVal) ?? localName(oVal),
|
||||
});
|
||||
}
|
||||
if (oVal === nodeId) {
|
||||
inbound.push({
|
||||
predicate: labelMap.get(pVal) ?? localName(pVal),
|
||||
subject: sVal,
|
||||
subjectLabel: labelMap.get(sVal) ?? localName(sVal),
|
||||
});
|
||||
}
|
||||
for (const triple of triples) {
|
||||
const subject = termValue(triple.s);
|
||||
const predicate = termValue(triple.p);
|
||||
const object = termValue(triple.o);
|
||||
if (predicate === RDFS_LABEL || predicate === RDF_TYPE) continue;
|
||||
if (subject === nodeId) {
|
||||
outbound.push({
|
||||
predicate: labelMap.get(predicate) ?? localName(predicate),
|
||||
object,
|
||||
objectLabel: labelMap.get(object) ?? localName(object),
|
||||
});
|
||||
}
|
||||
return { outbound, inbound };
|
||||
}, [nodeId, triples, labelMap]);
|
||||
if (object === nodeId) {
|
||||
inbound.push({
|
||||
predicate: labelMap.get(predicate) ?? localName(predicate),
|
||||
subject,
|
||||
subjectLabel: labelMap.get(subject) ?? localName(subject),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-80 shrink-0 flex-col border-l border-border bg-surface-50">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between border-b border-border px-4 py-3">
|
||||
<h3 className="truncate text-sm font-semibold text-fg">{label}</h3>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="rounded p-1 text-fg-subtle hover:bg-surface-200 hover:text-fg"
|
||||
aria-label="Close detail panel"
|
||||
>
|
||||
<aside className="absolute right-4 top-4 z-20 max-h-[calc(100%-2rem)] w-96 overflow-y-auto rounded-lg border border-border bg-surface-50 shadow-xl">
|
||||
<div className="flex items-start justify-between gap-3 border-b border-border px-4 py-3">
|
||||
<div className="min-w-0">
|
||||
<h2 className="truncate text-sm font-semibold text-fg">{label}</h2>
|
||||
<p className="break-all font-mono text-[10px] text-fg-subtle">{nodeId}</p>
|
||||
</div>
|
||||
<button onClick={onClose} aria-label="Close node details" className="rounded p-1 text-fg-subtle hover:bg-surface-200 hover:text-fg">
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-4">
|
||||
<p className="mb-3 truncate font-mono text-[10px] text-fg-subtle">
|
||||
{nodeId}
|
||||
</p>
|
||||
|
||||
{/* Outbound relationships */}
|
||||
{related.outbound.length > 0 && (
|
||||
<div className="mb-4">
|
||||
<h4 className="mb-2 flex items-center gap-1.5 text-xs font-medium text-fg-muted">
|
||||
<ArrowRight className="h-3 w-3" />
|
||||
Outbound ({related.outbound.length})
|
||||
</h4>
|
||||
<div className="space-y-1">
|
||||
{related.outbound.map((r, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="flex items-center gap-1.5 rounded bg-surface-100 px-2 py-1.5 text-xs"
|
||||
>
|
||||
<Badge variant="default">{r.predicate}</Badge>
|
||||
<span className="truncate text-fg-muted">{r.objectLabel}</span>
|
||||
<div className="space-y-4 p-4">
|
||||
{outbound.length > 0 && (
|
||||
<section>
|
||||
<h3 className="mb-2 text-xs font-medium uppercase tracking-wider text-fg-subtle">Outgoing</h3>
|
||||
<div className="space-y-2">
|
||||
{outbound.map((edge, index) => (
|
||||
<div key={`${edge.object}-${index}`} className="rounded-md bg-surface-100 p-2 text-xs">
|
||||
<p className="text-fg-muted">{edge.predicate}</p>
|
||||
<p className="mt-0.5 break-all font-mono text-fg">{edge.objectLabel}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Inbound relationships */}
|
||||
{related.inbound.length > 0 && (
|
||||
<div>
|
||||
<h4 className="mb-2 flex items-center gap-1.5 text-xs font-medium text-fg-muted">
|
||||
<ArrowLeft className="h-3 w-3" />
|
||||
Inbound ({related.inbound.length})
|
||||
</h4>
|
||||
<div className="space-y-1">
|
||||
{related.inbound.map((r, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="flex items-center gap-1.5 rounded bg-surface-100 px-2 py-1.5 text-xs"
|
||||
>
|
||||
<span className="truncate text-fg-muted">{r.subjectLabel}</span>
|
||||
<Badge variant="default">{r.predicate}</Badge>
|
||||
{inbound.length > 0 && (
|
||||
<section>
|
||||
<h3 className="mb-2 text-xs font-medium uppercase tracking-wider text-fg-subtle">Incoming</h3>
|
||||
<div className="space-y-2">
|
||||
{inbound.map((edge, index) => (
|
||||
<div key={`${edge.subject}-${index}`} className="rounded-md bg-surface-100 p-2 text-xs">
|
||||
<p className="text-fg-muted">{edge.predicate}</p>
|
||||
<p className="mt-0.5 break-all font-mono text-fg">{edge.subjectLabel}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{related.outbound.length === 0 && related.inbound.length === 0 && (
|
||||
<p className="text-xs text-fg-subtle">No relationships found.</p>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Graph page
|
||||
// ---------------------------------------------------------------------------
|
||||
function paintNode(showLabels: boolean) {
|
||||
return (node: GraphNode, ctx: CanvasRenderingContext2D, globalScale: number) => {
|
||||
const radius = Math.max(3, Math.sqrt(node.degree + 1) * 2.2);
|
||||
const x = node.x ?? 0;
|
||||
const y = node.y ?? 0;
|
||||
ctx.beginPath();
|
||||
ctx.arc(x, y, radius, 0, 2 * Math.PI);
|
||||
ctx.fillStyle = node.color ?? "#5b80ff";
|
||||
ctx.fill();
|
||||
if (!showLabels || globalScale < 0.7) return;
|
||||
const fontSize = Math.max(10 / globalScale, 2);
|
||||
ctx.font = `${fontSize}px Inter, sans-serif`;
|
||||
ctx.textAlign = "center";
|
||||
ctx.textBaseline = "top";
|
||||
const light = document.documentElement.classList.contains("light");
|
||||
ctx.fillStyle = light ? "rgba(24,24,27,0.85)" : "rgba(250,250,250,0.85)";
|
||||
ctx.fillText(node.label, x, y + radius + 1);
|
||||
};
|
||||
}
|
||||
|
||||
function paintLink(link: GraphLink, ctx: CanvasRenderingContext2D, globalScale: number) {
|
||||
if (globalScale < 1.5) return;
|
||||
const source = link.source as unknown as GraphNode;
|
||||
const target = link.target as unknown as GraphNode;
|
||||
if (source.x === undefined || source.y === undefined || target.x === undefined || target.y === undefined) return;
|
||||
const midX = (source.x + target.x) / 2;
|
||||
const midY = (source.y + target.y) / 2;
|
||||
const fontSize = Math.max(8 / globalScale, 2);
|
||||
ctx.font = `${fontSize}px Inter, sans-serif`;
|
||||
ctx.textAlign = "center";
|
||||
ctx.textBaseline = "middle";
|
||||
ctx.fillStyle = "rgba(161,161,170,0.65)";
|
||||
ctx.fillText(link.label, midX, midY);
|
||||
}
|
||||
|
||||
export default function GraphPage() {
|
||||
const socket = useSocket();
|
||||
const flowId = useSessionStore((s) => s.flowId);
|
||||
const collection = useSettings((s) => s.settings.collection);
|
||||
const addActivity = useProgressStore((s) => s.addActivity);
|
||||
const removeActivity = useProgressStore((s) => s.removeActivity);
|
||||
|
||||
const [triples, setTriples] = useState<Triple[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [selectedNode, setSelectedNode] = useState<string | null>(null);
|
||||
|
||||
// Query filters
|
||||
const [showFilters, setShowFilters] = useState(false);
|
||||
const [subjectFilter, setSubjectFilter] = useState("");
|
||||
const [predicateFilter, setPredicateFilter] = useState("");
|
||||
const [objectFilter, setObjectFilter] = useState("");
|
||||
const [tripleLimit, setTripleLimit] = useState(2000);
|
||||
const [showLegend, setShowLegend] = useState(false);
|
||||
const hasActiveFilters =
|
||||
subjectFilter.length > 0 ||
|
||||
predicateFilter.length > 0 ||
|
||||
objectFilter.length > 0;
|
||||
|
||||
const fgRef = useRef<ForceGraphMethods<GraphNode, GraphLink> | undefined>(
|
||||
undefined,
|
||||
);
|
||||
const [containerSize, setContainerSize] = useState<{
|
||||
width: number;
|
||||
height: number;
|
||||
} | null>(null);
|
||||
const roRef = useRef<ResizeObserver | null>(null);
|
||||
|
||||
// Auto-fit tracking — declared early so fetchTriples can reset it
|
||||
const hasAutoFit = useRef(false);
|
||||
|
||||
// Ref callback — attaches ResizeObserver when the container mounts
|
||||
const containerRef = useCallback((el: HTMLDivElement | null) => {
|
||||
// Disconnect previous observer
|
||||
if (roRef.current !== null) {
|
||||
roRef.current.disconnect();
|
||||
roRef.current = null;
|
||||
}
|
||||
if (el === null) return;
|
||||
const ro = new ResizeObserver((entries) => {
|
||||
const entry = entries[0];
|
||||
if (entry !== undefined) {
|
||||
const { width, height } = entry.contentRect;
|
||||
setContainerSize({ width: Math.floor(width), height: Math.floor(height) });
|
||||
}
|
||||
});
|
||||
ro.observe(el);
|
||||
roRef.current = ro;
|
||||
}, []);
|
||||
|
||||
// Fetch triples with optional filters
|
||||
const fetchTriples = useCallback(async () => {
|
||||
const act = "Load graph";
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
addActivity(act);
|
||||
hasAutoFit.current = false;
|
||||
|
||||
const flow = socket.flow(flowId);
|
||||
const s: Term | undefined = subjectFilter.length > 0 ? { t: "i", i: subjectFilter } : undefined;
|
||||
const p: Term | undefined = predicateFilter.length > 0 ? { t: "i", i: predicateFilter } : undefined;
|
||||
const o: Term | undefined = objectFilter.length > 0 ? { t: "i", i: objectFilter } : undefined;
|
||||
|
||||
const result = await flow.triplesQuery(
|
||||
s,
|
||||
p,
|
||||
o,
|
||||
tripleLimit,
|
||||
collection,
|
||||
);
|
||||
setTriples(result);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
removeActivity(act);
|
||||
}
|
||||
}, [socket, flowId, collection, subjectFilter, predicateFilter, objectFilter, tripleLimit, addActivity, removeActivity]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchTriples();
|
||||
}, [fetchTriples]);
|
||||
|
||||
// Build graph
|
||||
const { data: graphData, labelMap, typeMap } = useMemo(
|
||||
() => triplesToGraph(Array.isArray(triples) ? triples : []),
|
||||
[triples],
|
||||
);
|
||||
|
||||
// Unique types for legend
|
||||
const uniqueTypes = useMemo(() => {
|
||||
const seen = new Map<string, string>();
|
||||
for (const [, typeUri] of typeMap) {
|
||||
const name = localName(typeUri);
|
||||
if (!seen.has(name)) {
|
||||
seen.set(name, typeUri);
|
||||
}
|
||||
}
|
||||
return Array.from(seen.entries());
|
||||
}, [typeMap]);
|
||||
|
||||
// Search filter -- highlight matching nodes
|
||||
const searchLower = searchTerm.toLowerCase();
|
||||
const matchingIds = useMemo(() => {
|
||||
if (searchLower.length === 0) return new Set<string>();
|
||||
return new Set(
|
||||
graphData.nodes
|
||||
.filter(
|
||||
(n) =>
|
||||
n.label.toLowerCase().includes(searchLower) ||
|
||||
n.id.toLowerCase().includes(searchLower),
|
||||
)
|
||||
.map((n) => n.id),
|
||||
);
|
||||
}, [graphData.nodes, searchLower]);
|
||||
|
||||
const selectedLabel = selectedNode !== null
|
||||
? labelMap.get(selectedNode) ?? localName(selectedNode)
|
||||
: "";
|
||||
|
||||
// Auto-fit graph to view once data loads
|
||||
useEffect(() => {
|
||||
if (
|
||||
graphData.nodes.length > 0 &&
|
||||
fgRef.current !== undefined &&
|
||||
hasAutoFit.current === false
|
||||
) {
|
||||
hasAutoFit.current = true;
|
||||
// Wait for force simulation to settle briefly before fitting
|
||||
const timer = setTimeout(() => fgRef.current?.zoomToFit(400, 40), 500);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [graphData.nodes.length]);
|
||||
|
||||
// Zoom helpers
|
||||
const zoomIn = () => fgRef.current?.zoom(2, 300);
|
||||
const zoomOut = () => fgRef.current?.zoom(0.5, 300);
|
||||
const zoomFit = () =>
|
||||
fgRef.current?.zoomToFit(400, 40);
|
||||
|
||||
// Node paint callback — with glow effect
|
||||
const paintNode = useCallback(
|
||||
(node: GraphNode, ctx: CanvasRenderingContext2D, globalScale: number) => {
|
||||
const isSelected = node.id === selectedNode;
|
||||
const isMatch = matchingIds.size > 0 && matchingIds.has(node.id);
|
||||
const dim = matchingIds.size > 0 && !isMatch && !isSelected;
|
||||
|
||||
const radius = Math.max(3, Math.sqrt(node.degree + 1) * 2.5);
|
||||
const x = node.x ?? 0;
|
||||
const y = node.y ?? 0;
|
||||
|
||||
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;
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
// Label
|
||||
const fontSize = Math.max(10 / globalScale, 2);
|
||||
ctx.font = `600 ${fontSize}px Inter, sans-serif`;
|
||||
ctx.textAlign = "center";
|
||||
ctx.textBaseline = "top";
|
||||
const isLight = document.documentElement.classList.contains("light");
|
||||
ctx.fillStyle = dim
|
||||
? "rgba(100,100,100,0.3)"
|
||||
: isLight
|
||||
? "rgba(24,24,27,0.9)"
|
||||
: "rgba(250,250,250,0.9)";
|
||||
ctx.fillText(node.label, x, y + radius + 2);
|
||||
},
|
||||
[selectedNode, matchingIds],
|
||||
);
|
||||
|
||||
// Link label painting
|
||||
const paintLink = useCallback(
|
||||
(link: GraphLink, ctx: CanvasRenderingContext2D, globalScale: number) => {
|
||||
if (globalScale < 1.5) return; // only show labels when zoomed in enough
|
||||
|
||||
const src = link.source as unknown as GraphNode;
|
||||
const tgt = link.target as unknown as GraphNode;
|
||||
if (
|
||||
src.x === undefined ||
|
||||
src.y === undefined ||
|
||||
tgt.x === undefined ||
|
||||
tgt.y === undefined
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const midX = ((src.x ?? 0) + (tgt.x ?? 0)) / 2;
|
||||
const midY = ((src.y ?? 0) + (tgt.y ?? 0)) / 2;
|
||||
|
||||
const fontSize = Math.max(8 / globalScale, 1.5);
|
||||
ctx.font = `${fontSize}px Inter, sans-serif`;
|
||||
ctx.textAlign = "center";
|
||||
ctx.textBaseline = "middle";
|
||||
ctx.fillStyle = "rgba(161,161,170,0.7)";
|
||||
ctx.fillText(link.label, midX, midY);
|
||||
},
|
||||
[],
|
||||
);
|
||||
const flowId = useAtomValue(flowIdAtom);
|
||||
const collection = useAtomValue(settingsAtom).collection;
|
||||
const [view, setView] = useAtom(graphViewAtom);
|
||||
const triplesResult = useAtomValue(graphTriplesAtom({ flowId, collection, limit: view.nodeLimit }));
|
||||
const refresh = useAtomRefresh(graphTriplesAtom({ flowId, collection, limit: view.nodeLimit }));
|
||||
const triples = resultData(triplesResult, []);
|
||||
const loading = resultLoading(triplesResult, triples);
|
||||
const error = resultError(triplesResult);
|
||||
const { data, labelMap, typeMap } = triplesToGraph(triples);
|
||||
const search = view.searchTerm.trim().toLowerCase();
|
||||
const graphData = search.length === 0
|
||||
? data
|
||||
: (() => {
|
||||
const nodes = data.nodes.filter((node) => node.label.toLowerCase().includes(search) || node.id.toLowerCase().includes(search));
|
||||
const nodeIds = new Set(nodes.map((node) => node.id));
|
||||
return {
|
||||
nodes,
|
||||
links: data.links.filter((link) => {
|
||||
const source = typeof link.source === "string" ? link.source : (link.source as GraphNode).id;
|
||||
const target = typeof link.target === "string" ? link.target : (link.target as GraphNode).id;
|
||||
return nodeIds.has(source) && nodeIds.has(target);
|
||||
}),
|
||||
};
|
||||
})();
|
||||
const selectedNode = view.selectedNodeId !== null
|
||||
? data.nodes.find((node) => node.id === view.selectedNodeId)
|
||||
: undefined;
|
||||
const uniqueTypes = Array.from(new Set(Array.from(typeMap.values()).map(localName))).sort();
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
{/* Header */}
|
||||
<div className="mb-4 flex flex-wrap items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-3">
|
||||
<Rotate3d className="h-6 w-6 text-brand-400" />
|
||||
<h1 className="text-2xl font-bold text-fg">Graph</h1>
|
||||
<span className="ml-2 rounded bg-surface-200 px-2 py-0.5 text-xs text-fg-muted">
|
||||
{graphData.nodes.length} nodes, {graphData.links.length} edges
|
||||
</span>
|
||||
<Badge>{graphData.nodes.length} nodes</Badge>
|
||||
<Badge>{graphData.links.length} edges</Badge>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{/* Search */}
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-fg-subtle" />
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-2 rounded-lg border border-border bg-surface-100 px-3 py-2">
|
||||
<Search className="h-4 w-4 text-fg-subtle" />
|
||||
<input
|
||||
id="graph-search"
|
||||
type="text"
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
placeholder="Search nodes..."
|
||||
aria-label="Search nodes"
|
||||
className="w-48 rounded-lg border border-border bg-surface-100 py-1.5 pl-8 pr-3 text-xs text-fg placeholder:text-fg-subtle focus:border-brand-500 focus:outline-none focus:ring-1 focus:ring-brand-500"
|
||||
value={view.searchTerm}
|
||||
onChange={(event) => setView({ ...view, searchTerm: event.target.value })}
|
||||
placeholder="Search graph..."
|
||||
className="w-48 bg-transparent text-sm text-fg placeholder:text-fg-subtle focus:outline-none"
|
||||
/>
|
||||
{searchTerm.length > 0 && (
|
||||
<button
|
||||
onClick={() => setSearchTerm("")}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 text-fg-subtle hover:text-fg"
|
||||
aria-label="Clear search"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Zoom controls */}
|
||||
<div className="flex rounded-lg border border-border bg-surface-100">
|
||||
<button
|
||||
onClick={zoomIn}
|
||||
className="px-2 py-1.5 text-fg-muted hover:text-fg"
|
||||
title="Zoom in"
|
||||
aria-label="Zoom in"
|
||||
>
|
||||
<ZoomIn className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={zoomOut}
|
||||
className="border-l border-r border-border px-2 py-1.5 text-fg-muted hover:text-fg"
|
||||
title="Zoom out"
|
||||
aria-label="Zoom out"
|
||||
>
|
||||
<ZoomOut className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={zoomFit}
|
||||
className="px-2 py-1.5 text-fg-muted hover:text-fg"
|
||||
title="Fit to view"
|
||||
aria-label="Fit to view"
|
||||
>
|
||||
<Maximize className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Filter toggle */}
|
||||
<button
|
||||
onClick={() => setShowFilters((p) => !p)}
|
||||
className={cn(
|
||||
"flex items-center gap-1.5 rounded-lg border px-3 py-1.5 text-xs transition-colors",
|
||||
showFilters || hasActiveFilters
|
||||
? "border-brand-500/50 bg-brand-600/10 text-brand-400"
|
||||
: "border-border text-fg-muted hover:bg-surface-200",
|
||||
)}
|
||||
title="Query filters"
|
||||
aria-label="Toggle query filters"
|
||||
aria-expanded={showFilters}
|
||||
<select
|
||||
value={view.nodeLimit}
|
||||
onChange={(event) => setView({ ...view, nodeLimit: Number(event.target.value) })}
|
||||
aria-label="Node limit"
|
||||
className="rounded-lg border border-border bg-surface-100 px-3 py-2 text-sm text-fg focus:border-brand-500 focus:outline-none"
|
||||
>
|
||||
<Filter className="h-3.5 w-3.5" />
|
||||
Filters
|
||||
{hasActiveFilters && !showFilters && (
|
||||
<span className="ml-0.5 h-1.5 w-1.5 rounded-full bg-brand-400" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Legend toggle */}
|
||||
{uniqueTypes.length > 0 && (
|
||||
<button
|
||||
onClick={() => setShowLegend((p) => !p)}
|
||||
className={cn(
|
||||
"flex items-center gap-1.5 rounded-lg border px-3 py-1.5 text-xs transition-colors",
|
||||
showLegend
|
||||
? "border-brand-500/50 bg-brand-600/10 text-brand-400"
|
||||
: "border-border text-fg-muted hover:bg-surface-200",
|
||||
)}
|
||||
title="Type legend"
|
||||
aria-label="Toggle type legend"
|
||||
aria-expanded={showLegend}
|
||||
>
|
||||
Legend
|
||||
</button>
|
||||
)}
|
||||
|
||||
{[100, 250, 500, 1000].map((limit) => <option key={limit} value={limit}>{limit}</option>)}
|
||||
</select>
|
||||
<button
|
||||
onClick={fetchTriples}
|
||||
onClick={refresh}
|
||||
disabled={loading}
|
||||
className="flex items-center gap-1.5 rounded-lg border border-border px-3 py-1.5 text-xs text-fg-muted hover:bg-surface-200 disabled:opacity-40"
|
||||
aria-label="Refresh graph"
|
||||
className="rounded-lg border border-border px-3 py-2 text-fg-muted hover:bg-surface-200 disabled:opacity-40"
|
||||
>
|
||||
{loading ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<Rotate3d className="h-3.5 w-3.5" />
|
||||
)}
|
||||
Reload
|
||||
<RefreshCwIcon loading={loading} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filter panel */}
|
||||
{showFilters && (
|
||||
<div className="mb-4 rounded-lg border border-border bg-surface-50 p-4">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<h3 className="flex items-center gap-2 text-xs font-medium text-fg-muted">
|
||||
<Filter className="h-3 w-3" />
|
||||
Query Filters
|
||||
</h3>
|
||||
{hasActiveFilters && (
|
||||
<button
|
||||
onClick={() => {
|
||||
setSubjectFilter("");
|
||||
setPredicateFilter("");
|
||||
setObjectFilter("");
|
||||
}}
|
||||
className="text-xs text-brand-400 hover:text-brand-300"
|
||||
>
|
||||
Clear all
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div className="space-y-1">
|
||||
<label htmlFor="filter-subject" className="block text-[10px] font-medium uppercase tracking-wider text-fg-subtle">
|
||||
Subject
|
||||
</label>
|
||||
<input
|
||||
id="filter-subject"
|
||||
type="text"
|
||||
value={subjectFilter}
|
||||
onChange={(e) => setSubjectFilter(e.target.value)}
|
||||
placeholder="URI filter..."
|
||||
className="w-full rounded-lg border border-border bg-surface-100 px-3 py-1.5 text-xs 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">
|
||||
<label htmlFor="filter-predicate" className="block text-[10px] font-medium uppercase tracking-wider text-fg-subtle">
|
||||
Predicate
|
||||
</label>
|
||||
<input
|
||||
id="filter-predicate"
|
||||
type="text"
|
||||
value={predicateFilter}
|
||||
onChange={(e) => setPredicateFilter(e.target.value)}
|
||||
placeholder="URI filter..."
|
||||
className="w-full rounded-lg border border-border bg-surface-100 px-3 py-1.5 text-xs 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">
|
||||
<label htmlFor="filter-object" className="block text-[10px] font-medium uppercase tracking-wider text-fg-subtle">
|
||||
Object
|
||||
</label>
|
||||
<input
|
||||
id="filter-object"
|
||||
type="text"
|
||||
value={objectFilter}
|
||||
onChange={(e) => setObjectFilter(e.target.value)}
|
||||
placeholder="URI filter..."
|
||||
className="w-full rounded-lg border border-border bg-surface-100 px-3 py-1.5 text-xs text-fg placeholder:text-fg-subtle focus:border-brand-500 focus:outline-none focus:ring-1 focus:ring-brand-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 flex items-center gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<label htmlFor="filter-limit" className="text-[10px] font-medium uppercase tracking-wider text-fg-subtle">
|
||||
Limit
|
||||
</label>
|
||||
<input
|
||||
id="filter-limit"
|
||||
type="range"
|
||||
min={100}
|
||||
max={5000}
|
||||
step={100}
|
||||
value={tripleLimit}
|
||||
onChange={(e) => setTripleLimit(Number(e.target.value))}
|
||||
className="w-24 accent-brand-500"
|
||||
/>
|
||||
<span className="text-xs text-fg-muted">{tripleLimit}</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={fetchTriples}
|
||||
disabled={loading}
|
||||
className="ml-auto flex items-center gap-1.5 rounded-lg bg-brand-600 px-4 py-1.5 text-xs font-medium text-white transition-colors hover:bg-brand-500 disabled:opacity-40"
|
||||
>
|
||||
Apply
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Content */}
|
||||
{error !== null && (
|
||||
<p className="mb-4 rounded-lg bg-error/10 px-4 py-2 text-sm text-error">
|
||||
{error}
|
||||
</p>
|
||||
<p className="mb-4 rounded-lg bg-error/10 px-4 py-2 text-sm text-error">{error}</p>
|
||||
)}
|
||||
|
||||
{loading && triples.length === 0 && (
|
||||
<div className="flex flex-1 items-center justify-center">
|
||||
<Loader2 className="mr-2 h-5 w-5 animate-spin text-fg-subtle" />
|
||||
<span className="text-fg-subtle">Loading graph data...</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="mb-3 flex flex-wrap items-center gap-2">
|
||||
<button
|
||||
onClick={() => setView({ ...view, showLabels: !view.showLabels })}
|
||||
className={cn("flex items-center gap-1.5 rounded-md border border-border px-2.5 py-1.5 text-xs", view.showLabels ? "bg-brand-600/10 text-brand-400" : "text-fg-muted")}
|
||||
>
|
||||
<Filter className="h-3.5 w-3.5" />
|
||||
Labels
|
||||
</button>
|
||||
{uniqueTypes.slice(0, 8).map((type) => <Badge key={type} variant="info">{type}</Badge>)}
|
||||
</div>
|
||||
|
||||
{!loading && graphData.nodes.length === 0 && (
|
||||
<div className="flex flex-1 items-center justify-center rounded-lg border border-dashed border-border">
|
||||
<div className="text-center">
|
||||
<Rotate3d className="mx-auto mb-3 h-10 w-10 text-fg-subtle opacity-30" />
|
||||
<p className="text-fg-subtle">No graph data in this collection.</p>
|
||||
<p className="mt-1 text-xs text-fg-subtle">
|
||||
Upload documents and process them to populate the knowledge graph.
|
||||
</p>
|
||||
<div className="relative min-h-0 flex-1 overflow-hidden rounded-lg border border-border bg-surface-50">
|
||||
{loading && triples.length === 0 && (
|
||||
<div className="absolute inset-0 z-10 flex items-center justify-center bg-surface-50">
|
||||
<Loader2 className="mr-2 h-5 w-5 animate-spin text-fg-subtle" />
|
||||
<span className="text-fg-subtle">Loading graph...</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
|
||||
{graphData.nodes.length > 0 && (
|
||||
<div className="relative flex flex-1 overflow-hidden rounded-lg border border-border">
|
||||
{/* Graph canvas */}
|
||||
<div ref={containerRef} className="relative min-w-0 flex-1 bg-surface-0">
|
||||
<Suspense fallback={<div className="flex h-full items-center justify-center"><Loader2 className="h-5 w-5 animate-spin text-fg-subtle" /></div>}>
|
||||
{!loading && graphData.nodes.length === 0 && (
|
||||
<div className="absolute inset-0 z-10 flex flex-col items-center justify-center">
|
||||
<Rotate3d className="mb-3 h-10 w-10 text-fg-subtle opacity-30" />
|
||||
<p className="text-fg-subtle">No graph triples available.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{graphData.nodes.length > 0 && (
|
||||
<Suspense fallback={<div className="flex h-full items-center justify-center text-fg-subtle">Loading graph renderer...</div>}>
|
||||
<ForceGraph2D
|
||||
ref={fgRef}
|
||||
graphData={graphData}
|
||||
nodeCanvasObject={paintNode}
|
||||
nodePointerAreaPaint={(node: GraphNode, color, ctx) => {
|
||||
const radius = Math.max(3, Math.sqrt(node.degree + 1) * 2.5);
|
||||
ctx.beginPath();
|
||||
ctx.arc(node.x ?? 0, node.y ?? 0, radius + 2, 0, 2 * Math.PI);
|
||||
ctx.fillStyle = color;
|
||||
ctx.fill();
|
||||
}}
|
||||
width={900}
|
||||
height={650}
|
||||
backgroundColor="rgba(0,0,0,0)"
|
||||
nodeCanvasObject={paintNode(view.showLabels)}
|
||||
linkCanvasObjectMode={() => "after"}
|
||||
linkCanvasObject={paintLink}
|
||||
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,
|
||||
);
|
||||
linkColor={() => "rgba(120,120,140,0.32)"}
|
||||
nodePointerAreaPaint={(node, color, ctx) => {
|
||||
ctx.fillStyle = color;
|
||||
ctx.beginPath();
|
||||
ctx.arc(node.x ?? 0, node.y ?? 0, Math.max(6, Math.sqrt(node.degree + 1) * 3), 0, 2 * Math.PI);
|
||||
ctx.fill();
|
||||
}}
|
||||
onBackgroundClick={() => setSelectedNode(null)}
|
||||
backgroundColor="transparent"
|
||||
cooldownTicks={100}
|
||||
warmupTicks={30}
|
||||
{...(containerSize !== null
|
||||
? { width: containerSize.width, height: containerSize.height }
|
||||
: {})}
|
||||
onNodeClick={(node) => setView({ ...view, selectedNodeId: node.id, selectedNodeLabel: node.label })}
|
||||
/>
|
||||
</Suspense>
|
||||
</Suspense>
|
||||
)}
|
||||
|
||||
{/* Search results badge overlay */}
|
||||
{searchTerm.length > 0 && matchingIds.size > 0 && (
|
||||
<div className="absolute bottom-3 left-3">
|
||||
<Badge variant="success">
|
||||
{matchingIds.size} match{matchingIds.size > 1 ? "es" : ""}
|
||||
</Badge>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Type legend overlay */}
|
||||
{showLegend && uniqueTypes.length > 0 && (
|
||||
<div className="absolute bottom-3 left-3 z-10 max-h-48 overflow-y-auto rounded-lg border border-border bg-surface-50/95 px-3 py-2 shadow-lg backdrop-blur-sm">
|
||||
<h4 className="mb-1.5 text-[10px] font-medium uppercase tracking-wider text-fg-subtle">
|
||||
Node Types
|
||||
</h4>
|
||||
<div className="space-y-1">
|
||||
{uniqueTypes.map(([name]) => (
|
||||
<div key={name} className="flex items-center gap-2 text-xs text-fg-muted">
|
||||
<span
|
||||
className="inline-block h-2.5 w-2.5 shrink-0 rounded-full"
|
||||
style={{ backgroundColor: hashColor(name) }}
|
||||
/>
|
||||
<span className="truncate">{name}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Detail panel -- positioned absolutely so it overlays the graph */}
|
||||
{selectedNode !== null && (
|
||||
<div className="absolute inset-y-0 right-0 z-10">
|
||||
<NodeDetailPanel
|
||||
nodeId={selectedNode}
|
||||
label={selectedLabel}
|
||||
triples={triples}
|
||||
labelMap={labelMap}
|
||||
onClose={() => setSelectedNode(null)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{selectedNode !== undefined && view.selectedNodeId !== null && (
|
||||
<NodeDetailPanel
|
||||
nodeId={view.selectedNodeId}
|
||||
label={view.selectedNodeLabel ?? selectedNode.label}
|
||||
triples={triples}
|
||||
labelMap={labelMap}
|
||||
onClose={() => setView({ ...view, selectedNodeId: null, selectedNodeLabel: null })}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RefreshCwIcon({ loading }: { loading: boolean }) {
|
||||
return loading ? <Loader2 className="h-4 w-4 animate-spin" /> : <RefreshCw className="h-4 w-4" />;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useAtom, useAtomRefresh, useAtomSet, useAtomValue } from "@effect/atom-react";
|
||||
import {
|
||||
BrainCircuit,
|
||||
Loader2,
|
||||
|
|
@ -8,30 +8,30 @@ import {
|
|||
AlertTriangle,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useSocket } from "@/providers/socket-provider";
|
||||
import { useConnectionState } from "@/providers/socket-provider";
|
||||
import { useNotification } from "@/providers/notification-provider";
|
||||
import { useSessionStore } from "@/hooks/use-session-store";
|
||||
import {
|
||||
activeActionAtom,
|
||||
deleteKgCoreAtom,
|
||||
kgCoresAtom,
|
||||
knowledgeDeleteTargetAtom,
|
||||
loadKgCoreAtom,
|
||||
resultData,
|
||||
resultError,
|
||||
resultLoading,
|
||||
} from "@/atoms/workbench";
|
||||
import { Dialog } from "@/components/ui/dialog";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Delete confirmation dialog
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function DeleteCoreDialog({
|
||||
open,
|
||||
coreId,
|
||||
onClose,
|
||||
onConfirm,
|
||||
}: {
|
||||
open: boolean;
|
||||
coreId: string;
|
||||
coreId: string | null;
|
||||
onClose: () => void;
|
||||
onConfirm: () => void;
|
||||
}) {
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
open={coreId !== null}
|
||||
onClose={onClose}
|
||||
title="Delete Knowledge Core"
|
||||
footer={
|
||||
|
|
@ -55,7 +55,7 @@ function DeleteCoreDialog({
|
|||
<AlertTriangle className="mt-0.5 h-5 w-5 shrink-0 text-error" />
|
||||
<p className="text-sm text-fg-muted">
|
||||
Are you sure you want to delete knowledge core{" "}
|
||||
<span className="font-mono font-medium text-fg">{coreId}</span>?
|
||||
<span className="font-mono font-medium text-fg">{coreId ?? ""}</span>?
|
||||
This action cannot be undone.
|
||||
</p>
|
||||
</div>
|
||||
|
|
@ -63,93 +63,20 @@ function DeleteCoreDialog({
|
|||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Knowledge Cores page
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export default function KnowledgeCoresPage() {
|
||||
const socket = useSocket();
|
||||
const connectionState = useConnectionState();
|
||||
const notify = useNotification();
|
||||
const flowId = useSessionStore((s) => s.flowId);
|
||||
const result = useAtomValue(kgCoresAtom);
|
||||
const refresh = useAtomRefresh(kgCoresAtom);
|
||||
const loadCore = useAtomSet(loadKgCoreAtom);
|
||||
const deleteCore = useAtomSet(deleteKgCoreAtom);
|
||||
const [deleteTarget, setDeleteTarget] = useAtom(knowledgeDeleteTargetAtom);
|
||||
const actionInProgress = useAtomValue(activeActionAtom);
|
||||
|
||||
const [cores, setCores] = useState<string[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [deleteTarget, setDeleteTarget] = useState<string | null>(null);
|
||||
const [actionInProgress, setActionInProgress] = useState<string | null>(null);
|
||||
|
||||
const loadCores = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const timeoutPromise = new Promise<never>((_, reject) =>
|
||||
setTimeout(() => reject(new Error("Request timed out")), 15000),
|
||||
);
|
||||
const ids = await Promise.race([
|
||||
socket.knowledge().getKnowledgeCores(),
|
||||
timeoutPromise,
|
||||
]);
|
||||
setCores(Array.isArray(ids) ? ids : []);
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
setError(msg);
|
||||
console.error("Failed to load knowledge cores:", err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [socket]);
|
||||
|
||||
// Auto-load when connected
|
||||
useEffect(() => {
|
||||
const connected =
|
||||
connectionState.status === "connected" ||
|
||||
connectionState.status === "authenticated" ||
|
||||
connectionState.status === "unauthenticated";
|
||||
if (connected) {
|
||||
loadCores();
|
||||
}
|
||||
}, [connectionState.status, loadCores]);
|
||||
|
||||
const handleLoad = useCallback(
|
||||
async (id: string) => {
|
||||
setActionInProgress(id);
|
||||
try {
|
||||
await socket.knowledge().loadKgCore(id, flowId);
|
||||
notify.success("Core loaded", `Knowledge core "${id}" has been loaded.`);
|
||||
} catch (err) {
|
||||
notify.error(
|
||||
"Failed to load core",
|
||||
err instanceof Error ? err.message : String(err),
|
||||
);
|
||||
} finally {
|
||||
setActionInProgress(null);
|
||||
}
|
||||
},
|
||||
[socket, flowId, notify],
|
||||
);
|
||||
|
||||
const handleDelete = useCallback(async () => {
|
||||
if (deleteTarget === null || deleteTarget.length === 0) return;
|
||||
setActionInProgress(deleteTarget);
|
||||
try {
|
||||
await socket.knowledge().deleteKgCore(deleteTarget);
|
||||
notify.success("Core deleted", `Knowledge core "${deleteTarget}" has been deleted.`);
|
||||
await loadCores();
|
||||
} catch (err) {
|
||||
notify.error(
|
||||
"Failed to delete core",
|
||||
err instanceof Error ? err.message : String(err),
|
||||
);
|
||||
} finally {
|
||||
setActionInProgress(null);
|
||||
setDeleteTarget(null);
|
||||
}
|
||||
}, [socket, deleteTarget, notify, loadCores]);
|
||||
const cores = resultData(result, []);
|
||||
const loading = resultLoading(result, cores);
|
||||
const error = resultError(result);
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
{/* Header */}
|
||||
<div className="mb-6 flex flex-wrap items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-3">
|
||||
<BrainCircuit className="h-6 w-6 text-brand-400" />
|
||||
|
|
@ -162,7 +89,7 @@ export default function KnowledgeCoresPage() {
|
|||
</div>
|
||||
|
||||
<button
|
||||
onClick={loadCores}
|
||||
onClick={refresh}
|
||||
disabled={loading}
|
||||
className="flex items-center gap-1.5 rounded-lg border border-border px-3 py-2 text-sm text-fg-muted transition-colors hover:bg-surface-200 disabled:opacity-40"
|
||||
>
|
||||
|
|
@ -171,7 +98,6 @@ export default function KnowledgeCoresPage() {
|
|||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
{loading && cores.length === 0 && (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="mr-2 h-5 w-5 animate-spin text-fg-subtle" />
|
||||
|
|
@ -179,7 +105,7 @@ export default function KnowledgeCoresPage() {
|
|||
</div>
|
||||
)}
|
||||
|
||||
{error !== null && error.length > 0 && (
|
||||
{error !== null && (
|
||||
<p className="mb-4 rounded-lg bg-error/10 px-4 py-2 text-sm text-error">
|
||||
{error}
|
||||
</p>
|
||||
|
|
@ -210,7 +136,7 @@ export default function KnowledgeCoresPage() {
|
|||
<td className="px-4 py-3 text-right">
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<button
|
||||
onClick={() => handleLoad(id)}
|
||||
onClick={() => loadCore(id)}
|
||||
disabled={actionInProgress === id}
|
||||
className="flex items-center gap-1.5 rounded px-2.5 py-1.5 text-xs font-medium text-brand-400 hover:bg-brand-600/10 disabled:opacity-40"
|
||||
title="Load core"
|
||||
|
|
@ -242,12 +168,13 @@ export default function KnowledgeCoresPage() {
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* Delete confirmation dialog */}
|
||||
<DeleteCoreDialog
|
||||
open={deleteTarget != null}
|
||||
coreId={deleteTarget ?? ""}
|
||||
coreId={deleteTarget}
|
||||
onClose={() => setDeleteTarget(null)}
|
||||
onConfirm={handleDelete}
|
||||
onConfirm={() => {
|
||||
if (deleteTarget !== null) deleteCore(deleteTarget);
|
||||
setDeleteTarget(null);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
|
|
@ -1,4 +1,4 @@
|
|||
import { useCallback, useState } from "react";
|
||||
import { useAtom, useAtomRefresh, useAtomValue } from "@effect/atom-react";
|
||||
import {
|
||||
MessageCircleCode,
|
||||
Loader2,
|
||||
|
|
@ -9,47 +9,40 @@ import {
|
|||
Terminal,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { usePrompts } from "@/hooks/use-prompts";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Prompts page
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type Tab = "templates" | "system";
|
||||
import {
|
||||
promptActiveTabAtom,
|
||||
promptDetailAtom,
|
||||
promptsAtom,
|
||||
resultData,
|
||||
resultError,
|
||||
resultLoading,
|
||||
selectedPromptIdAtom,
|
||||
systemPromptAtom,
|
||||
} from "@/atoms/workbench";
|
||||
|
||||
export default function PromptsPage() {
|
||||
const { prompts, systemPrompt, loading, error, loadPrompts, loadSystemPrompt, getPrompt } = usePrompts();
|
||||
const promptsResult = useAtomValue(promptsAtom);
|
||||
const systemPromptResult = useAtomValue(systemPromptAtom);
|
||||
const refreshPrompts = useAtomRefresh(promptsAtom);
|
||||
const refreshSystemPrompt = useAtomRefresh(systemPromptAtom);
|
||||
const [activeTab, setActiveTab] = useAtom(promptActiveTabAtom);
|
||||
const [selectedPromptId, setSelectedPromptId] = useAtom(selectedPromptIdAtom);
|
||||
const promptDetailResult = useAtomValue(promptDetailAtom(selectedPromptId ?? ""));
|
||||
|
||||
const [activeTab, setActiveTab] = useState<Tab>("templates");
|
||||
const [selectedPromptId, setSelectedPromptId] = useState<string | null>(null);
|
||||
const [promptDetail, setPromptDetail] = useState<{ system?: string; prompt?: string } | string | null>(null);
|
||||
const [loadingDetail, setLoadingDetail] = useState(false);
|
||||
const prompts = resultData(promptsResult, []);
|
||||
const systemPrompt = resultData(systemPromptResult, "");
|
||||
const loading = resultLoading(promptsResult, prompts) || resultLoading(systemPromptResult, systemPrompt);
|
||||
const error = resultError(promptsResult) ?? resultError(systemPromptResult);
|
||||
const promptDetail = resultData(promptDetailResult, null) as { system?: string; prompt?: string } | string | null;
|
||||
const loadingDetail = selectedPromptId !== null && resultLoading(promptDetailResult, promptDetail);
|
||||
|
||||
const handleSelectPrompt = useCallback(
|
||||
async (id: string) => {
|
||||
setSelectedPromptId(id);
|
||||
setLoadingDetail(true);
|
||||
try {
|
||||
const detail = await getPrompt(id);
|
||||
setPromptDetail(detail as typeof promptDetail);
|
||||
} catch (err) {
|
||||
console.error("Failed to load prompt detail:", err);
|
||||
setPromptDetail("Error loading prompt.");
|
||||
} finally {
|
||||
setLoadingDetail(false);
|
||||
}
|
||||
},
|
||||
[getPrompt],
|
||||
);
|
||||
|
||||
const handleRefresh = useCallback(() => {
|
||||
loadPrompts();
|
||||
loadSystemPrompt();
|
||||
}, [loadPrompts, loadSystemPrompt]);
|
||||
const refresh = () => {
|
||||
refreshPrompts();
|
||||
refreshSystemPrompt();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
{/* Header */}
|
||||
<div className="mb-6 flex flex-wrap items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-3">
|
||||
<MessageCircleCode className="h-6 w-6 text-brand-400" />
|
||||
|
|
@ -57,7 +50,7 @@ export default function PromptsPage() {
|
|||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleRefresh}
|
||||
onClick={refresh}
|
||||
disabled={loading}
|
||||
className="flex items-center gap-1.5 rounded-lg border border-border px-3 py-2 text-sm text-fg-muted transition-colors hover:bg-surface-200 disabled:opacity-40"
|
||||
>
|
||||
|
|
@ -66,7 +59,6 @@ export default function PromptsPage() {
|
|||
</button>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div role="tablist" aria-label="Prompt sections" className="mb-4 flex gap-1 rounded-lg bg-surface-100 p-1">
|
||||
<button
|
||||
id="tab-templates"
|
||||
|
|
@ -75,9 +67,7 @@ export default function PromptsPage() {
|
|||
onClick={() => setActiveTab("templates")}
|
||||
className={cn(
|
||||
"flex items-center gap-2 rounded-md px-4 py-2 text-sm font-medium transition-colors",
|
||||
activeTab === "templates"
|
||||
? "bg-surface-50 text-fg shadow-sm"
|
||||
: "text-fg-muted hover:text-fg",
|
||||
activeTab === "templates" ? "bg-surface-50 text-fg shadow-sm" : "text-fg-muted hover:text-fg",
|
||||
)}
|
||||
>
|
||||
<FileText className="h-3.5 w-3.5" />
|
||||
|
|
@ -90,9 +80,7 @@ export default function PromptsPage() {
|
|||
onClick={() => setActiveTab("system")}
|
||||
className={cn(
|
||||
"flex items-center gap-2 rounded-md px-4 py-2 text-sm font-medium transition-colors",
|
||||
activeTab === "system"
|
||||
? "bg-surface-50 text-fg shadow-sm"
|
||||
: "text-fg-muted hover:text-fg",
|
||||
activeTab === "system" ? "bg-surface-50 text-fg shadow-sm" : "text-fg-muted hover:text-fg",
|
||||
)}
|
||||
>
|
||||
<Terminal className="h-3.5 w-3.5" />
|
||||
|
|
@ -100,14 +88,12 @@ export default function PromptsPage() {
|
|||
</button>
|
||||
</div>
|
||||
|
||||
{/* Error display */}
|
||||
{error !== null && error.length > 0 && (
|
||||
{error !== null && (
|
||||
<p className="mb-4 rounded-lg bg-error/10 px-4 py-2 text-sm text-error">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Templates tab */}
|
||||
{activeTab === "templates" && (
|
||||
<div id="panel-templates" role="tabpanel" aria-labelledby="tab-templates" tabIndex={0} className="flex flex-1 flex-col gap-4 overflow-hidden">
|
||||
{loading && prompts.length === 0 && (
|
||||
|
|
@ -125,21 +111,20 @@ export default function PromptsPage() {
|
|||
)}
|
||||
|
||||
{prompts.length > 0 && (
|
||||
<div className="flex flex-1 gap-4 overflow-hidden">
|
||||
{/* Prompt list */}
|
||||
<div className="w-80 shrink-0 overflow-y-auto rounded-lg border border-border">
|
||||
<div className="flex flex-1 flex-col gap-4 overflow-hidden lg:flex-row">
|
||||
<div className="max-h-56 w-full shrink-0 overflow-y-auto rounded-lg border border-border lg:max-h-none lg:w-80">
|
||||
<div className="border-b border-border bg-surface-100 px-4 py-3">
|
||||
<h2 className="text-xs font-medium uppercase tracking-wider text-fg-muted">
|
||||
Templates ({prompts.length})
|
||||
</h2>
|
||||
</div>
|
||||
<div className="divide-y divide-border">
|
||||
{prompts.map((p) => {
|
||||
const id = p.id ?? (p as Record<string, unknown>).name ?? String(p);
|
||||
{prompts.map((prompt) => {
|
||||
const id = prompt.id ?? (prompt as Record<string, unknown>).name ?? String(prompt);
|
||||
return (
|
||||
<button
|
||||
key={String(id)}
|
||||
onClick={() => handleSelectPrompt(String(id))}
|
||||
onClick={() => setSelectedPromptId(String(id))}
|
||||
className={cn(
|
||||
"flex w-full items-center justify-between px-4 py-3 text-left text-sm transition-colors",
|
||||
selectedPromptId === String(id)
|
||||
|
|
@ -155,8 +140,7 @@ export default function PromptsPage() {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{/* Prompt detail */}
|
||||
<div className="flex flex-1 flex-col overflow-hidden rounded-lg border border-border">
|
||||
<div className="min-h-0 flex flex-1 flex-col overflow-hidden rounded-lg border border-border">
|
||||
{selectedPromptId !== null && selectedPromptId.length > 0 ? (
|
||||
<>
|
||||
<div className="flex items-center justify-between border-b border-border bg-surface-100 px-4 py-3">
|
||||
|
|
@ -164,10 +148,8 @@ export default function PromptsPage() {
|
|||
<span className="font-mono">{selectedPromptId}</span>
|
||||
</h2>
|
||||
<button
|
||||
onClick={() => {
|
||||
setSelectedPromptId(null);
|
||||
setPromptDetail("");
|
||||
}}
|
||||
onClick={() => setSelectedPromptId(null)}
|
||||
aria-label="Close prompt detail"
|
||||
className="rounded-md p-1 text-fg-subtle hover:bg-surface-200 hover:text-fg"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
|
|
@ -220,7 +202,6 @@ export default function PromptsPage() {
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* System Prompt tab */}
|
||||
{activeTab === "system" && (
|
||||
<div id="panel-system" role="tabpanel" aria-labelledby="tab-system" tabIndex={0} className="flex flex-1 flex-col overflow-hidden rounded-lg border border-border">
|
||||
<div className="border-b border-border bg-surface-100 px-4 py-3">
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useAtom, useAtomSet, useAtomValue } from "@effect/atom-react";
|
||||
import {
|
||||
Settings as SettingsIcon,
|
||||
Wifi,
|
||||
|
|
@ -9,42 +9,44 @@ import {
|
|||
Database,
|
||||
Workflow,
|
||||
Info,
|
||||
Loader2,
|
||||
Moon,
|
||||
Sun,
|
||||
Plus,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useSettings } from "@/providers/settings-provider";
|
||||
import { useSocket } from "@/providers/socket-provider";
|
||||
import { useConnectionState } from "@/providers/socket-provider";
|
||||
import { useFlows } from "@/hooks/use-flows";
|
||||
import { useSessionStore } from "@/hooks/use-session-store";
|
||||
import { useNotification } from "@/providers/notification-provider";
|
||||
import type * as React from "react";
|
||||
import {
|
||||
collectionFormAtom,
|
||||
collectionsAtom,
|
||||
connectionStateAtom,
|
||||
createCollectionAtom,
|
||||
createCollectionDialogOpenAtom,
|
||||
deleteCollectionAtom,
|
||||
deleteCollectionDialogOpenAtom,
|
||||
flowIdAtom,
|
||||
flowsAtom,
|
||||
resultData,
|
||||
settingsAtom,
|
||||
settingsShowApiKeyAtom,
|
||||
setSettingsFieldAtom,
|
||||
themeAtom,
|
||||
toggleThemeAtom,
|
||||
updateFeatureSwitchesAtom,
|
||||
} from "@/atoms/workbench";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Dialog } from "@/components/ui/dialog";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const ACRONYMS: Record<string, string> = { mcp: "MCP", llm: "LLM", api: "API" };
|
||||
|
||||
/** Convert camelCase key to display label, preserving known acronyms. */
|
||||
function featureLabel(key: string): string {
|
||||
return key
|
||||
.replace(/([A-Z])/g, " $1")
|
||||
.trim()
|
||||
.split(" ")
|
||||
.map((w) => ACRONYMS[w.toLowerCase()] ?? w.charAt(0).toUpperCase() + w.slice(1))
|
||||
.map((word) => ACRONYMS[word.toLowerCase()] ?? word.charAt(0).toUpperCase() + word.slice(1))
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Section wrapper
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function Section({
|
||||
title,
|
||||
icon,
|
||||
|
|
@ -65,181 +67,27 @@ function Section({
|
|||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Settings page
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export default function SettingsPage() {
|
||||
const { settings, updateSetting, updateFeatureSwitches } = useSettings();
|
||||
const connectionState = useConnectionState();
|
||||
const socket = useSocket();
|
||||
const { flows } = useFlows();
|
||||
const notify = useNotification();
|
||||
const settings = useAtomValue(settingsAtom);
|
||||
const setField = useAtomSet(setSettingsFieldAtom);
|
||||
const updateFeatureSwitches = useAtomSet(updateFeatureSwitchesAtom);
|
||||
const connectionState = useAtomValue(connectionStateAtom);
|
||||
const flows = resultData(useAtomValue(flowsAtom), []);
|
||||
const [flowId, setFlowId] = useAtom(flowIdAtom);
|
||||
const [showApiKey, setShowApiKey] = useAtom(settingsShowApiKeyAtom);
|
||||
const [theme] = useAtom(themeAtom);
|
||||
const toggleTheme = useAtomSet(toggleThemeAtom);
|
||||
const collections = resultData(useAtomValue(collectionsAtom), []);
|
||||
const [createOpen, setCreateOpen] = useAtom(createCollectionDialogOpenAtom);
|
||||
const [deleteOpen, setDeleteOpen] = useAtom(deleteCollectionDialogOpenAtom);
|
||||
const [collectionForm, setCollectionForm] = useAtom(collectionFormAtom);
|
||||
const createCollection = useAtomSet(createCollectionAtom);
|
||||
const deleteCollection = useAtomSet(deleteCollectionAtom);
|
||||
|
||||
const flowId = useSessionStore((s) => s.flowId);
|
||||
const setFlowId = useSessionStore((s) => s.setFlowId);
|
||||
|
||||
const [showApiKey, setShowApiKey] = useState(false);
|
||||
const [collections, setCollections] = useState<
|
||||
Array<{ id?: string; name?: string; [key: string]: unknown }>
|
||||
>([]);
|
||||
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;
|
||||
const saved = localStorage.getItem("tg-theme");
|
||||
if (saved !== null) return saved === "dark";
|
||||
return !document.documentElement.classList.contains("light");
|
||||
});
|
||||
|
||||
const toggleTheme = useCallback(() => {
|
||||
const next = !isDark;
|
||||
setIsDark(next);
|
||||
if (next) {
|
||||
document.documentElement.classList.remove("light");
|
||||
document.body.classList.remove("light");
|
||||
document.body.classList.add("dark");
|
||||
localStorage.setItem("tg-theme", "dark");
|
||||
} else {
|
||||
document.documentElement.classList.add("light");
|
||||
document.body.classList.add("light");
|
||||
document.body.classList.remove("dark");
|
||||
localStorage.setItem("tg-theme", "light");
|
||||
}
|
||||
}, [isDark]);
|
||||
|
||||
// Reusable function to fetch collections from the backend
|
||||
const refreshCollections = useCallback(() => {
|
||||
setLoadingCollections(true);
|
||||
return socket
|
||||
.collectionManagement()
|
||||
.listCollections()
|
||||
.then((cols) => {
|
||||
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(() => {
|
||||
// Fallback: at minimum show "default"
|
||||
setCollections([{ id: "default", collection: "default", name: "default" }]);
|
||||
})
|
||||
.finally(() => {
|
||||
setLoadingCollections(false);
|
||||
});
|
||||
}, [socket]);
|
||||
|
||||
// Fetch collections on mount
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
refreshCollections().then(() => {
|
||||
if (cancelled) return;
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [refreshCollections]);
|
||||
|
||||
// Create a new collection
|
||||
const handleCreateCollection = useCallback(async () => {
|
||||
const trimmedId = newId.trim();
|
||||
if (trimmedId.length === 0) return;
|
||||
|
||||
setCreating(true);
|
||||
try {
|
||||
const tags = newTags
|
||||
.split(",")
|
||||
.map((t) => t.trim())
|
||||
.filter((tag) => tag.length > 0);
|
||||
|
||||
await socket
|
||||
.collectionManagement()
|
||||
.updateCollection(
|
||||
trimmedId,
|
||||
newName.trim().length > 0 ? newName.trim() : undefined,
|
||||
newDescription.trim().length > 0 ? 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.length === 0) 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 =
|
||||
connectionState.status === "connected" ||
|
||||
connectionState.status === "authenticated" ||
|
||||
connectionState.status === "unauthenticated";
|
||||
|
||||
const isWarning = connectionState.status === "unauthenticated";
|
||||
const statusBadge = isConnected ? (
|
||||
<Badge variant={isWarning ? "info" : "success"}>
|
||||
|
|
@ -253,418 +101,235 @@ export default function SettingsPage() {
|
|||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
{/* Header */}
|
||||
<div className="mb-6 flex items-center gap-3">
|
||||
<SettingsIcon className="h-6 w-6 text-brand-400" />
|
||||
<h1 className="text-2xl font-bold text-fg">Settings</h1>
|
||||
</div>
|
||||
|
||||
{/* Form */}
|
||||
<div className="max-w-2xl space-y-5 pb-8 overflow-y-auto">
|
||||
{/* Connection */}
|
||||
<Section
|
||||
title="Connection"
|
||||
icon={<Wifi className="h-4 w-4 text-fg-subtle" />}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-sm text-fg-muted">Status:</span>
|
||||
<div className="grid gap-6 lg:grid-cols-2">
|
||||
<Section title="Connection" icon={<Wifi className="h-4 w-4 text-brand-400" />}>
|
||||
<div className="flex items-center justify-between rounded-lg border border-border bg-surface-100 px-4 py-3">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-fg">Gateway status</p>
|
||||
{connectionState.lastError !== undefined && (
|
||||
<p className="mt-0.5 text-xs text-error">{connectionState.lastError}</p>
|
||||
)}
|
||||
</div>
|
||||
{statusBadge}
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label htmlFor="settings-gateway-url" className="block text-sm font-medium text-fg-muted">
|
||||
Gateway URL
|
||||
</label>
|
||||
<input
|
||||
id="settings-gateway-url"
|
||||
type="text"
|
||||
value={settings.gatewayUrl}
|
||||
onChange={(e) => updateSetting("gatewayUrl", e.target.value)}
|
||||
placeholder="Leave blank to use the default proxy"
|
||||
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 Beep Graph gateway.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label htmlFor="settings-user-id" className="block text-sm font-medium text-fg-muted">
|
||||
User ID
|
||||
</label>
|
||||
<input
|
||||
id="settings-user-id"
|
||||
type="text"
|
||||
value={settings.user}
|
||||
onChange={(e) => updateSetting("user", 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>
|
||||
</Section>
|
||||
|
||||
{/* Authentication */}
|
||||
<Section
|
||||
title="Authentication"
|
||||
icon={<Key className="h-4 w-4 text-fg-subtle" />}
|
||||
>
|
||||
<div className="space-y-1.5">
|
||||
<label htmlFor="settings-api-key" className="block text-sm font-medium text-fg-muted">
|
||||
API Key
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="mb-1 flex items-center gap-1.5 text-sm font-medium text-fg-muted">
|
||||
<Key className="h-3.5 w-3.5" /> API Key
|
||||
</span>
|
||||
<div className="relative">
|
||||
<input
|
||||
id="settings-api-key"
|
||||
type={showApiKey ? "text" : "password"}
|
||||
value={settings.apiKey}
|
||||
onChange={(e) => updateSetting("apiKey", e.target.value)}
|
||||
placeholder="Leave blank for unauthenticated access"
|
||||
className="w-full rounded-lg border border-border bg-surface-100 px-4 py-2 pr-10 text-sm text-fg placeholder:text-fg-subtle focus:border-brand-500 focus:outline-none focus:ring-1 focus:ring-brand-500"
|
||||
onChange={(event) => setField({ key: "apiKey", value: event.target.value })}
|
||||
placeholder="Optional gateway bearer token"
|
||||
className="w-full rounded-lg border border-border bg-surface-100 px-3 py-2 pr-10 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={() => setShowApiKey((p) => !p)}
|
||||
onClick={() => setShowApiKey(!showApiKey)}
|
||||
aria-label={showApiKey ? "Hide API key" : "Show API key"}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-fg-subtle hover:text-fg"
|
||||
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-fg-subtle hover:text-fg"
|
||||
>
|
||||
{showApiKey ? (
|
||||
<EyeOff className="h-4 w-4" />
|
||||
) : (
|
||||
<Eye className="h-4 w-4" />
|
||||
)}
|
||||
{showApiKey ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-xs text-fg-subtle">
|
||||
Changing the API key will reconnect the WebSocket.
|
||||
</p>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<label className="block">
|
||||
<span className="mb-1 block text-sm font-medium text-fg-muted">Gateway URL</span>
|
||||
<input
|
||||
type="text"
|
||||
value={settings.gatewayUrl}
|
||||
onChange={(event) => setField({ key: "gatewayUrl", value: event.target.value })}
|
||||
placeholder="/api/v1/rpc"
|
||||
className="w-full rounded-lg border border-border bg-surface-100 px-3 py-2 text-sm text-fg placeholder:text-fg-subtle focus:border-brand-500 focus:outline-none focus:ring-1 focus:ring-brand-500"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="block">
|
||||
<span className="mb-1 block text-sm font-medium text-fg-muted">User</span>
|
||||
<input
|
||||
type="text"
|
||||
value={settings.user}
|
||||
onChange={(event) => setField({ key: "user", value: event.target.value })}
|
||||
className="w-full rounded-lg border border-border bg-surface-100 px-3 py-2 text-sm text-fg placeholder:text-fg-subtle focus:border-brand-500 focus:outline-none focus:ring-1 focus:ring-brand-500"
|
||||
/>
|
||||
</label>
|
||||
</Section>
|
||||
|
||||
{/* Collection */}
|
||||
<Section
|
||||
title="Collection"
|
||||
icon={<Database className="h-4 w-4 text-fg-subtle" />}
|
||||
>
|
||||
<div className="space-y-1.5">
|
||||
<label htmlFor="settings-collection" className="block text-sm font-medium text-fg-muted">
|
||||
Active Collection
|
||||
</label>
|
||||
{loadingCollections ? (
|
||||
<div className="flex items-center gap-2 py-2 text-xs text-fg-subtle">
|
||||
<Loader2 className="h-3 w-3 animate-spin" /> Loading
|
||||
collections...
|
||||
</div>
|
||||
) : collections.length > 0 ? (
|
||||
<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>
|
||||
) : (
|
||||
<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().length === 0 || 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"
|
||||
icon={<Workflow className="h-4 w-4 text-fg-subtle" />}
|
||||
>
|
||||
<div className="space-y-1.5">
|
||||
<label htmlFor="settings-flow" className="block text-sm font-medium text-fg-muted">
|
||||
Flow
|
||||
</label>
|
||||
{flows.length > 0 ? (
|
||||
<Section title="Workspace" icon={<Database className="h-4 w-4 text-brand-400" />}>
|
||||
<label className="block">
|
||||
<span className="mb-1 block text-sm font-medium text-fg-muted">Collection</span>
|
||||
<div className="flex gap-2">
|
||||
<select
|
||||
id="settings-flow"
|
||||
value={flowId}
|
||||
onChange={(e) => setFlowId(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"
|
||||
value={settings.collection}
|
||||
onChange={(event) => setField({ key: "collection", value: event.target.value })}
|
||||
className="min-w-0 flex-1 rounded-lg border border-border bg-surface-100 px-3 py-2 text-sm text-fg focus:border-brand-500 focus:outline-none focus:ring-1 focus:ring-brand-500"
|
||||
>
|
||||
<option value="default">default</option>
|
||||
{flows.map((f) => (
|
||||
<option key={f.id} value={f.id}>
|
||||
{f.id}
|
||||
{f.description !== undefined && f.description.length > 0 ? ` -- ${f.description}` : ""}
|
||||
</option>
|
||||
))}
|
||||
{collections.map((collection) => {
|
||||
const id = String(collection.collection ?? collection.id ?? collection.name ?? "default");
|
||||
return (
|
||||
<option key={id} value={id}>
|
||||
{id}
|
||||
</option>
|
||||
);
|
||||
})}
|
||||
</select>
|
||||
) : (
|
||||
<input
|
||||
id="settings-flow"
|
||||
type="text"
|
||||
value={flowId}
|
||||
onChange={(e) => setFlowId(e.target.value)}
|
||||
placeholder="default"
|
||||
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 flow ID used for chat, graph queries, and document processing.
|
||||
</p>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{/* Theme */}
|
||||
<Section
|
||||
title="Appearance"
|
||||
icon={isDark ? <Moon className="h-4 w-4 text-fg-subtle" /> : <Sun className="h-4 w-4 text-fg-subtle" />}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-fg">Theme</p>
|
||||
<p className="text-xs text-fg-subtle">
|
||||
Toggle between dark and light mode.
|
||||
</p>
|
||||
<p className="text-xs text-fg-subtle">
|
||||
Currently using {isDark ? "dark" : "light"} mode.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCreateOpen(true)}
|
||||
className="rounded-lg border border-border px-3 py-2 text-fg-muted hover:bg-surface-200 hover:text-fg"
|
||||
aria-label="Create collection"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDeleteOpen(true)}
|
||||
className="rounded-lg border border-border px-3 py-2 text-error hover:bg-error/10"
|
||||
aria-label="Delete collection"
|
||||
disabled={settings.collection === "default"}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
role="switch"
|
||||
aria-checked={isDark}
|
||||
aria-label="Dark mode"
|
||||
onClick={toggleTheme}
|
||||
className={cn(
|
||||
"relative inline-flex h-6 w-11 items-center rounded-full transition-colors",
|
||||
isDark ? "bg-brand-600" : "bg-fg-subtle",
|
||||
)}
|
||||
</label>
|
||||
|
||||
<label className="block">
|
||||
<span className="mb-1 flex items-center gap-1.5 text-sm font-medium text-fg-muted">
|
||||
<Workflow className="h-3.5 w-3.5" /> Flow
|
||||
</span>
|
||||
<select
|
||||
value={flowId}
|
||||
onChange={(event) => setFlowId(event.target.value)}
|
||||
className="w-full rounded-lg border border-border bg-surface-100 px-3 py-2 text-sm text-fg focus:border-brand-500 focus:outline-none focus:ring-1 focus:ring-brand-500"
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"inline-block h-4 w-4 rounded-full bg-white transition-transform",
|
||||
isDark ? "translate-x-6" : "translate-x-1",
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
<option value="default">default</option>
|
||||
{flows.map((flow) => (
|
||||
<option key={flow.id} value={flow.id}>
|
||||
{flow.id}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleTheme(null)}
|
||||
className="flex w-full items-center justify-between rounded-lg border border-border bg-surface-100 px-4 py-3 text-sm text-fg hover:bg-surface-200"
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
{theme === "dark" ? <Moon className="h-4 w-4" /> : <Sun className="h-4 w-4" />}
|
||||
Theme
|
||||
</span>
|
||||
<span className="capitalize text-fg-muted">{theme}</span>
|
||||
</button>
|
||||
</Section>
|
||||
|
||||
{/* Feature Switches */}
|
||||
<Section
|
||||
title="Feature Switches"
|
||||
icon={<SettingsIcon className="h-4 w-4 text-fg-subtle" />}
|
||||
>
|
||||
{Object.entries(settings.featureSwitches).map(([key, enabled]) => {
|
||||
const isEnabled = enabled === true;
|
||||
return (
|
||||
<div key={key} className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-fg">{featureLabel(key)}</p>
|
||||
</div>
|
||||
<button
|
||||
role="switch"
|
||||
aria-checked={isEnabled}
|
||||
aria-label={featureLabel(key)}
|
||||
onClick={() => updateFeatureSwitches({ [key]: !isEnabled })}
|
||||
className={cn(
|
||||
"relative inline-flex h-6 w-11 items-center rounded-full transition-colors",
|
||||
isEnabled ? "bg-brand-600" : "bg-fg-subtle",
|
||||
)}
|
||||
>
|
||||
<span className={cn(
|
||||
"inline-block h-4 w-4 rounded-full bg-white transition-transform",
|
||||
isEnabled ? "translate-x-6" : "translate-x-1",
|
||||
)} />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</Section>
|
||||
|
||||
{/* About */}
|
||||
<Section
|
||||
title="About"
|
||||
icon={<Info className="h-4 w-4 text-fg-subtle" />}
|
||||
>
|
||||
<div className="space-y-2 text-sm text-fg-muted">
|
||||
<p>
|
||||
<span className="font-medium text-fg">Beep Graph</span>{" "}
|
||||
v0.1.0
|
||||
</p>
|
||||
<p>
|
||||
A web-based interface for interacting with the Beep Graph
|
||||
knowledge-graph system.
|
||||
</p>
|
||||
<Section title="Feature Switches" icon={<Info className="h-4 w-4 text-brand-400" />}>
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
{Object.entries(settings.featureSwitches).map(([key, value]) => (
|
||||
<label
|
||||
key={key}
|
||||
className="flex items-center justify-between rounded-lg border border-border bg-surface-100 px-3 py-2 text-sm"
|
||||
>
|
||||
<span className="text-fg-muted">{featureLabel(key)}</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={value}
|
||||
onChange={(event) => updateFeatureSwitches({ [key]: event.target.checked })}
|
||||
className="h-4 w-4 accent-brand-500"
|
||||
/>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</Section>
|
||||
</div>
|
||||
|
||||
<Dialog
|
||||
open={createOpen}
|
||||
onClose={() => setCreateOpen(false)}
|
||||
title="Create Collection"
|
||||
footer={
|
||||
<>
|
||||
<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
|
||||
onClick={() => {
|
||||
if (collectionForm.id.trim().length === 0) return;
|
||||
createCollection(collectionForm);
|
||||
setCollectionForm({ id: "", name: "", description: "", tags: "", submitting: false });
|
||||
setCreateOpen(false);
|
||||
}}
|
||||
disabled={collectionForm.id.trim().length === 0}
|
||||
className="rounded-lg bg-brand-600 px-4 py-2 text-sm font-medium text-white hover:bg-brand-500 disabled:opacity-40"
|
||||
>
|
||||
Create
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="space-y-3">
|
||||
{[
|
||||
["id", "Collection ID", "research"] as const,
|
||||
["name", "Display Name", "Research"] as const,
|
||||
["description", "Description", "Optional description"] as const,
|
||||
["tags", "Tags", "comma, separated"] as const,
|
||||
].map(([key, label, placeholder]) => (
|
||||
<label key={key} className="block">
|
||||
<span className="mb-1 block text-sm font-medium text-fg-muted">{label}</span>
|
||||
<input
|
||||
value={collectionForm[key]}
|
||||
onChange={(event) => setCollectionForm({ ...collectionForm, [key]: event.target.value })}
|
||||
placeholder={placeholder}
|
||||
className="w-full rounded-lg border border-border bg-surface-100 px-3 py-2 text-sm text-fg placeholder:text-fg-subtle focus:border-brand-500 focus:outline-none focus:ring-1 focus:ring-brand-500"
|
||||
/>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</Dialog>
|
||||
|
||||
<Dialog
|
||||
open={deleteOpen}
|
||||
onClose={() => setDeleteOpen(false)}
|
||||
title="Delete Collection"
|
||||
footer={
|
||||
<>
|
||||
<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
|
||||
onClick={() => {
|
||||
deleteCollection(settings.collection);
|
||||
setDeleteOpen(false);
|
||||
}}
|
||||
className="rounded-lg bg-error px-4 py-2 text-sm font-medium text-white hover:opacity-90"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<Trash2 className="mt-0.5 h-5 w-5 shrink-0 text-error" />
|
||||
<p className="text-sm text-fg-muted">
|
||||
Delete <span className="font-mono text-fg">{settings.collection}</span> and its data?
|
||||
</p>
|
||||
</div>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,72 +1,22 @@
|
|||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useAtomRefresh, useAtomValue } from "@effect/atom-react";
|
||||
import { Coins, Loader2, RefreshCw } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useSocket } from "@/providers/socket-provider";
|
||||
import { useConnectionState } from "@/providers/socket-provider";
|
||||
import { resultData, resultError, resultLoading, tokenCostsAtom } from "@/atoms/workbench";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface TokenCost {
|
||||
model: string;
|
||||
input_price: number;
|
||||
output_price: number;
|
||||
function formatPrice(price: number) {
|
||||
if (!Number.isFinite(price)) return "--";
|
||||
return `$${price.toFixed(2)}`;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Token Cost page
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export default function TokenCostPage() {
|
||||
const socket = useSocket();
|
||||
const connectionState = useConnectionState();
|
||||
const [costs, setCosts] = useState<TokenCost[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const loadCosts = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const data = await socket.config().getTokenCosts();
|
||||
setCosts(
|
||||
Array.isArray(data)
|
||||
? data.map((d: Record<string, unknown>) => ({
|
||||
model: String(d.model ?? ""),
|
||||
input_price: Number(d.input_price ?? 0),
|
||||
output_price: Number(d.output_price ?? 0),
|
||||
}))
|
||||
: [],
|
||||
);
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
setError(msg);
|
||||
console.error("Failed to load token costs:", err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [socket]);
|
||||
|
||||
// Auto-load when connected
|
||||
useEffect(() => {
|
||||
const connected =
|
||||
connectionState.status === "connected" ||
|
||||
connectionState.status === "authenticated" ||
|
||||
connectionState.status === "unauthenticated";
|
||||
if (connected) {
|
||||
loadCosts();
|
||||
}
|
||||
}, [connectionState.status, loadCosts]);
|
||||
|
||||
const formatPrice = (price: number) => {
|
||||
if (!Number.isFinite(price)) return "--";
|
||||
return `$${price.toFixed(2)}`;
|
||||
};
|
||||
const result = useAtomValue(tokenCostsAtom);
|
||||
const refresh = useAtomRefresh(tokenCostsAtom);
|
||||
const costs = resultData(result, []);
|
||||
const loading = resultLoading(result, costs);
|
||||
const error = resultError(result);
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
{/* Header */}
|
||||
<div className="mb-6 flex flex-wrap items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-3">
|
||||
<Coins className="h-6 w-6 text-brand-400" />
|
||||
|
|
@ -79,7 +29,7 @@ export default function TokenCostPage() {
|
|||
</div>
|
||||
|
||||
<button
|
||||
onClick={loadCosts}
|
||||
onClick={refresh}
|
||||
disabled={loading}
|
||||
className="flex items-center gap-1.5 rounded-lg border border-border px-3 py-2 text-sm text-fg-muted transition-colors hover:bg-surface-200 disabled:opacity-40"
|
||||
>
|
||||
|
|
@ -88,7 +38,6 @@ export default function TokenCostPage() {
|
|||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
{loading && costs.length === 0 && (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="mr-2 h-5 w-5 animate-spin text-fg-subtle" />
|
||||
|
|
@ -96,7 +45,7 @@ export default function TokenCostPage() {
|
|||
</div>
|
||||
)}
|
||||
|
||||
{error !== null && error.length > 0 && (
|
||||
{error !== null && (
|
||||
<p className="mb-4 rounded-lg bg-error/10 px-4 py-2 text-sm text-error">
|
||||
{error}
|
||||
</p>
|
||||
|
|
|
|||
|
|
@ -1,97 +0,0 @@
|
|||
import { create } from "zustand";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type NotificationType = "success" | "error" | "warning" | "info";
|
||||
|
||||
export interface Notification {
|
||||
id: string;
|
||||
type: NotificationType;
|
||||
title: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Store
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface NotificationState {
|
||||
notifications: Notification[];
|
||||
|
||||
addNotification: (
|
||||
type: NotificationType,
|
||||
title: string,
|
||||
description?: string,
|
||||
) => string;
|
||||
|
||||
removeNotification: (id: string) => void;
|
||||
|
||||
/** Convenience wrappers */
|
||||
success: (title: string, description?: string) => string;
|
||||
error: (title: string, description?: string) => string;
|
||||
warning: (title: string, description?: string) => string;
|
||||
info: (title: string, description?: string) => string;
|
||||
}
|
||||
|
||||
let _nextId = 0;
|
||||
function nextId(): string {
|
||||
return `notif-${++_nextId}-${Date.now()}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Simple toast-notification system backed by Zustand.
|
||||
*
|
||||
* Components can call `useNotification().success("Done!")` and render the
|
||||
* current `notifications` array however they like (e.g. a shadcn Toast list).
|
||||
*
|
||||
* Notifications are auto-dismissed after 5 seconds.
|
||||
*/
|
||||
export const useNotification = create<NotificationState>()((set, get) => {
|
||||
const AUTO_DISMISS_MS = 5_000;
|
||||
|
||||
const addNotification: NotificationState["addNotification"] = (
|
||||
type,
|
||||
title,
|
||||
description,
|
||||
) => {
|
||||
const id = nextId();
|
||||
const notification: Notification = {
|
||||
id,
|
||||
type,
|
||||
title,
|
||||
...(description !== undefined ? { description } : {}),
|
||||
};
|
||||
|
||||
set((state) => ({
|
||||
notifications: [...state.notifications, notification],
|
||||
}));
|
||||
|
||||
// Auto-dismiss
|
||||
setTimeout(() => {
|
||||
get().removeNotification(id);
|
||||
}, AUTO_DISMISS_MS);
|
||||
|
||||
return id;
|
||||
};
|
||||
|
||||
return {
|
||||
notifications: [],
|
||||
|
||||
addNotification,
|
||||
|
||||
removeNotification: (id) =>
|
||||
set((state) => ({
|
||||
notifications: state.notifications.filter((n) => n.id !== id),
|
||||
})),
|
||||
|
||||
success: (title, description) =>
|
||||
addNotification("success", title, description),
|
||||
error: (title, description) =>
|
||||
addNotification("error", title, description),
|
||||
warning: (title, description) =>
|
||||
addNotification("warning", title, description),
|
||||
info: (title, description) => addNotification("info", title, description),
|
||||
};
|
||||
});
|
||||
|
|
@ -1,110 +0,0 @@
|
|||
import { create } from "zustand";
|
||||
import { persist } from "zustand/middleware";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface FeatureSwitches {
|
||||
flowClasses: boolean;
|
||||
submissions: boolean;
|
||||
tokenCost: boolean;
|
||||
schemas: boolean;
|
||||
structuredQuery: boolean;
|
||||
ontologyEditor: boolean;
|
||||
agentTools: boolean;
|
||||
mcpTools: boolean;
|
||||
llmModels: boolean;
|
||||
}
|
||||
|
||||
export interface Settings {
|
||||
/** Display name / identifier sent with every request */
|
||||
user: string;
|
||||
/** Optional API key for gateway authentication */
|
||||
apiKey: string;
|
||||
/** Active knowledge-graph collection */
|
||||
collection: string;
|
||||
/** Gateway base URL (used when building the WebSocket URL) */
|
||||
gatewayUrl: string;
|
||||
/** Toggle optional sections of the UI */
|
||||
featureSwitches: FeatureSwitches;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Defaults
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const DEFAULT_FEATURE_SWITCHES: FeatureSwitches = {
|
||||
flowClasses: false,
|
||||
submissions: false,
|
||||
tokenCost: false,
|
||||
schemas: false,
|
||||
structuredQuery: false,
|
||||
ontologyEditor: false,
|
||||
agentTools: false,
|
||||
mcpTools: false,
|
||||
llmModels: false,
|
||||
};
|
||||
|
||||
const DEFAULT_SETTINGS: Settings = {
|
||||
user: "default",
|
||||
apiKey: "",
|
||||
collection: "default",
|
||||
gatewayUrl: "",
|
||||
featureSwitches: DEFAULT_FEATURE_SWITCHES,
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Store
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface SettingsState {
|
||||
settings: Settings;
|
||||
isLoaded: boolean;
|
||||
|
||||
/** Replace the entire settings object */
|
||||
setSettings: (settings: Settings) => void;
|
||||
|
||||
/** Update a single top-level key */
|
||||
updateSetting: <K extends keyof Settings>(
|
||||
key: K,
|
||||
value: Settings[K],
|
||||
) => void;
|
||||
|
||||
/** Merge partial feature-switch overrides */
|
||||
updateFeatureSwitches: (partial: Partial<FeatureSwitches>) => void;
|
||||
}
|
||||
|
||||
export const useSettings = create<SettingsState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
settings: DEFAULT_SETTINGS,
|
||||
isLoaded: true as boolean,
|
||||
|
||||
setSettings: (settings) => set({ settings }),
|
||||
|
||||
updateSetting: (key, value) =>
|
||||
set((state) => ({
|
||||
settings: { ...state.settings, [key]: value },
|
||||
})),
|
||||
|
||||
updateFeatureSwitches: (partial) =>
|
||||
set((state) => ({
|
||||
settings: {
|
||||
...state.settings,
|
||||
featureSwitches: {
|
||||
...state.settings.featureSwitches,
|
||||
...partial,
|
||||
},
|
||||
},
|
||||
})),
|
||||
}),
|
||||
{
|
||||
name: "trustgraph-settings",
|
||||
// Mark loaded once rehydration completes
|
||||
onRehydrateStorage: () => (state) => {
|
||||
if (state !== undefined) state.isLoaded = true;
|
||||
},
|
||||
},
|
||||
),
|
||||
);
|
||||
|
|
@ -1,132 +0,0 @@
|
|||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
useSyncExternalStore,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { BaseApi, type ConnectionState } from "@trustgraph/client";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Context
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface SocketContextValue {
|
||||
api: BaseApi;
|
||||
}
|
||||
|
||||
const SocketContext = createContext<SocketContextValue | null>(null);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Provider
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface SocketProviderProps {
|
||||
/** Username sent with every API request */
|
||||
user: string;
|
||||
/** Optional API key for authenticated connections */
|
||||
apiKey?: string;
|
||||
/** WebSocket URL (defaults to "/api/socket", proxied by Vite in dev) */
|
||||
socketUrl?: string;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* SocketProvider creates a single BaseApi instance that lives for the
|
||||
* lifetime of the provider and tears down the WebSocket on unmount.
|
||||
*
|
||||
* The BaseApi is recreated if `user`, `apiKey`, or `socketUrl` change.
|
||||
*/
|
||||
export function SocketProvider({
|
||||
user,
|
||||
apiKey,
|
||||
socketUrl,
|
||||
children,
|
||||
}: SocketProviderProps) {
|
||||
const apiRef = useRef<BaseApi | null>(null);
|
||||
|
||||
// Re-create the API instance when connection parameters change.
|
||||
// We track a serial number so downstream consumers re-render.
|
||||
const [serial, setSerial] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
// Close the previous socket if it exists
|
||||
apiRef.current?.close();
|
||||
|
||||
const api = new BaseApi(user, apiKey, socketUrl);
|
||||
apiRef.current = api;
|
||||
setSerial((s) => s + 1);
|
||||
|
||||
return () => {
|
||||
api.close();
|
||||
if (apiRef.current === api) {
|
||||
apiRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [user, apiKey, socketUrl]);
|
||||
|
||||
// Don't render children until the first API instance is ready
|
||||
if (apiRef.current === null) return null;
|
||||
|
||||
return (
|
||||
<SocketContext.Provider
|
||||
// eslint-disable-next-line react/no-children-prop
|
||||
key={serial}
|
||||
value={{ api: apiRef.current }}
|
||||
>
|
||||
{children}
|
||||
</SocketContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Hooks
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Returns the shared BaseApi instance.
|
||||
*
|
||||
* Must be called inside a `<SocketProvider>`.
|
||||
*/
|
||||
export function useSocket(): BaseApi {
|
||||
const ctx = useContext(SocketContext);
|
||||
if (ctx === null) {
|
||||
throw new Error("useSocket must be used within a <SocketProvider>");
|
||||
}
|
||||
return ctx.api;
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribes to connection-state changes emitted by BaseApi.
|
||||
*
|
||||
* Uses `useSyncExternalStore` for tear-free reads.
|
||||
*/
|
||||
export function useConnectionState(): ConnectionState {
|
||||
const api = useSocket();
|
||||
|
||||
// We store the latest snapshot in a ref so the getSnapshot function is stable.
|
||||
const stateRef = useRef<ConnectionState>({
|
||||
status: "connecting",
|
||||
hasApiKey: false,
|
||||
});
|
||||
|
||||
// subscribe must be stable across renders to prevent useSyncExternalStore
|
||||
// from re-subscribing on every render (which would cause an infinite loop
|
||||
// because onConnectionStateChange immediately calls the listener).
|
||||
const subscribe = useCallback(
|
||||
(onStoreChange: () => void) => {
|
||||
return api.onConnectionStateChange((next) => {
|
||||
stateRef.current = next;
|
||||
onStoreChange();
|
||||
});
|
||||
},
|
||||
[api],
|
||||
);
|
||||
|
||||
const getSnapshot = useCallback(() => stateRef.current, []);
|
||||
|
||||
return useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
|
||||
}
|
||||
51
ts/packages/workbench/src/qa/initial-values.ts
Normal file
51
ts/packages/workbench/src/qa/initial-values.ts
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
import type * as Atom from "effect/unstable/reactivity/Atom";
|
||||
import {
|
||||
apiFactoryAtom,
|
||||
DEFAULT_SETTINGS,
|
||||
flowIdAtom,
|
||||
settingsAtom,
|
||||
type FeatureSwitches,
|
||||
type Settings,
|
||||
type WorkbenchApiFactory,
|
||||
} from "@/atoms/workbench";
|
||||
import { makeMockBaseApi, qaSettingsFromFixture, type MockWorkbenchFixture } from "@/qa/mock-api";
|
||||
|
||||
export interface WorkbenchQaWindowConfig {
|
||||
readonly enabled?: boolean;
|
||||
readonly fixture?: MockWorkbenchFixture;
|
||||
readonly flowId?: string;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__TRUSTGRAPH_WORKBENCH_QA__?: WorkbenchQaWindowConfig;
|
||||
}
|
||||
}
|
||||
|
||||
function qaSettings(fixture: MockWorkbenchFixture | undefined): Settings {
|
||||
const fixtureSettings = qaSettingsFromFixture(fixture);
|
||||
return {
|
||||
...DEFAULT_SETTINGS,
|
||||
...fixtureSettings,
|
||||
featureSwitches: {
|
||||
...DEFAULT_SETTINGS.featureSwitches,
|
||||
...fixtureSettings.featureSwitches,
|
||||
} as FeatureSwitches,
|
||||
};
|
||||
}
|
||||
|
||||
export function getWorkbenchQaInitialValues(): Iterable<readonly [Atom.Atom<unknown>, unknown]> | undefined {
|
||||
if (typeof window === "undefined") return undefined;
|
||||
const config = window.__TRUSTGRAPH_WORKBENCH_QA__;
|
||||
if (config?.enabled !== true) return undefined;
|
||||
const fixture = config.fixture ?? {};
|
||||
const api = makeMockBaseApi(fixture);
|
||||
const apiFactory: WorkbenchApiFactory = {
|
||||
create: () => api,
|
||||
};
|
||||
return [
|
||||
[apiFactoryAtom as Atom.Atom<unknown>, apiFactory],
|
||||
[settingsAtom as Atom.Atom<unknown>, qaSettings(fixture)],
|
||||
[flowIdAtom as Atom.Atom<unknown>, config.flowId ?? "default"],
|
||||
];
|
||||
}
|
||||
578
ts/packages/workbench/src/qa/mock-api.ts
Normal file
578
ts/packages/workbench/src/qa/mock-api.ts
Normal file
|
|
@ -0,0 +1,578 @@
|
|||
import { BaseApi, type ConnectionState, type DocumentMetadata, type ProcessingMetadata, type StreamingMetadata, type Triple } from "@trustgraph/client";
|
||||
import { Option, Schema as S } from "effect";
|
||||
|
||||
type ConfigValues = Record<string, Record<string, unknown>>;
|
||||
|
||||
export interface MockWorkbenchFixture {
|
||||
readonly settings?: {
|
||||
readonly user?: string;
|
||||
readonly apiKey?: string;
|
||||
readonly gatewayUrl?: string;
|
||||
readonly collection?: string;
|
||||
readonly featureSwitches?: Record<string, boolean>;
|
||||
};
|
||||
readonly flows?: {
|
||||
readonly activeIds?: string[];
|
||||
readonly definitions?: Record<string, Record<string, unknown>>;
|
||||
readonly blueprints?: Record<string, Record<string, unknown>>;
|
||||
};
|
||||
readonly config?: {
|
||||
readonly prompt?: Record<string, unknown>;
|
||||
readonly valuesByType?: ConfigValues;
|
||||
};
|
||||
readonly library?: {
|
||||
readonly documents?: DocumentMetadata[];
|
||||
readonly processing?: ProcessingMetadata[];
|
||||
readonly metadataById?: Record<string, DocumentMetadata>;
|
||||
};
|
||||
readonly knowledge?: {
|
||||
readonly kgCores?: string[];
|
||||
readonly deCores?: string[];
|
||||
readonly loadedKgCores?: string[];
|
||||
};
|
||||
readonly collections?: Array<Record<string, unknown>>;
|
||||
readonly graph?: {
|
||||
readonly triplesByFlowCollection?: Record<string, Triple[]>;
|
||||
readonly explainTriplesByGraph?: Record<string, Triple[]>;
|
||||
};
|
||||
readonly chat?: {
|
||||
readonly delayFrames?: number;
|
||||
};
|
||||
}
|
||||
|
||||
interface UploadSession {
|
||||
readonly metadata: DocumentMetadata;
|
||||
readonly chunks: string[];
|
||||
readonly totalSize: number;
|
||||
readonly chunkSize: number;
|
||||
readonly totalChunks: number;
|
||||
}
|
||||
|
||||
interface MockState {
|
||||
readonly settings: Required<NonNullable<MockWorkbenchFixture["settings"]>>;
|
||||
readonly flows: {
|
||||
readonly activeIds: string[];
|
||||
readonly definitions: Record<string, Record<string, unknown>>;
|
||||
readonly blueprints: Record<string, Record<string, unknown>>;
|
||||
};
|
||||
readonly config: {
|
||||
readonly prompt: Record<string, unknown>;
|
||||
readonly valuesByType: ConfigValues;
|
||||
};
|
||||
readonly library: {
|
||||
readonly documents: DocumentMetadata[];
|
||||
readonly processing: ProcessingMetadata[];
|
||||
readonly metadataById: Record<string, DocumentMetadata>;
|
||||
readonly uploads: Record<string, UploadSession>;
|
||||
};
|
||||
readonly knowledge: {
|
||||
readonly kgCores: string[];
|
||||
readonly deCores: string[];
|
||||
readonly loadedKgCores: string[];
|
||||
};
|
||||
readonly collections: Array<Record<string, unknown>>;
|
||||
readonly graph: {
|
||||
readonly triplesByFlowCollection: Record<string, Triple[]>;
|
||||
readonly explainTriplesByGraph: Record<string, Triple[]>;
|
||||
};
|
||||
readonly chat: {
|
||||
readonly delayFrames: number;
|
||||
};
|
||||
}
|
||||
|
||||
interface MockBaseApi extends BaseApi {
|
||||
makeRequest<RequestType extends object, ResponseType>(
|
||||
service: string,
|
||||
request: RequestType,
|
||||
timeout?: number,
|
||||
retries?: number,
|
||||
flow?: string,
|
||||
): Promise<ResponseType>;
|
||||
makeRequestMulti<RequestType extends object, ResponseType>(
|
||||
service: string,
|
||||
request: RequestType,
|
||||
receiver: (resp: unknown) => boolean,
|
||||
timeout?: number,
|
||||
retries?: number,
|
||||
flow?: string,
|
||||
): Promise<ResponseType>;
|
||||
}
|
||||
|
||||
const encodeJsonUnknown = S.encodeUnknownOption(S.fromJsonString(S.Unknown));
|
||||
const decodeJsonUnknown = S.decodeUnknownOption(S.UnknownFromJsonString);
|
||||
|
||||
const iri = (value: string) => ({ t: "i" as const, i: value });
|
||||
const literal = (value: string) => ({ t: "l" as const, v: value });
|
||||
|
||||
function encodeJson(value: unknown): string {
|
||||
return Option.getOrElse(encodeJsonUnknown(value), () => "{}");
|
||||
}
|
||||
|
||||
function decodeJson(value: unknown): unknown {
|
||||
if (typeof value !== "string") return value;
|
||||
return Option.getOrElse(decodeJsonUnknown(value), () => value);
|
||||
}
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> {
|
||||
return value !== null && typeof value === "object" ? value as Record<string, unknown> : {};
|
||||
}
|
||||
|
||||
function stringValue(value: unknown, fallback = ""): string {
|
||||
return typeof value === "string" && value.length > 0 ? value : fallback;
|
||||
}
|
||||
|
||||
function numberValue(value: unknown, fallback: number): number {
|
||||
return typeof value === "number" && Number.isFinite(value) ? value : fallback;
|
||||
}
|
||||
|
||||
function withDefaultCollection(value: unknown): string {
|
||||
return stringValue(value, "default");
|
||||
}
|
||||
|
||||
function graphKey(flow: string | undefined, collection: unknown): string {
|
||||
return `${flow ?? "default"}:${withDefaultCollection(collection)}`;
|
||||
}
|
||||
|
||||
function cloneArray<A>(items: readonly A[] | undefined): A[] {
|
||||
return items === undefined ? [] : [...items];
|
||||
}
|
||||
|
||||
function defaultConfigValues(): ConfigValues {
|
||||
return {
|
||||
mcp: {
|
||||
"qa-search": encodeJson({
|
||||
url: "http://localhost:8383/mcp",
|
||||
"remote-name": "qa-search",
|
||||
"auth-token": "qa-token",
|
||||
}),
|
||||
},
|
||||
tool: {
|
||||
"qa-search-tool": encodeJson({
|
||||
type: "mcp-tool",
|
||||
name: "QA Search",
|
||||
description: "Search tool used by browser QA",
|
||||
"mcp-tool": "qa-search",
|
||||
group: ["default"],
|
||||
arguments: [{ name: "query", type: "string", description: "Search query" }],
|
||||
}),
|
||||
},
|
||||
"token-cost": {
|
||||
"qa-model": encodeJson({ input_price: 1.25, output_price: 2.5 }),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function defaultTriples(): Triple[] {
|
||||
return [
|
||||
{ s: iri("https://example.test/Alice"), p: iri("http://www.w3.org/2000/01/rdf-schema#label"), o: literal("Alice") },
|
||||
{ s: iri("https://example.test/Bob"), p: iri("http://www.w3.org/2000/01/rdf-schema#label"), o: literal("Bob") },
|
||||
{ s: iri("https://example.test/Acme"), p: iri("http://www.w3.org/2000/01/rdf-schema#label"), o: literal("Acme") },
|
||||
{ s: iri("https://example.test/Alice"), p: iri("https://schema.org/worksFor"), o: iri("https://example.test/Acme") },
|
||||
{ s: iri("https://example.test/Alice"), p: iri("https://schema.org/knows"), o: iri("https://example.test/Bob") },
|
||||
];
|
||||
}
|
||||
|
||||
function createState(fixture: MockWorkbenchFixture = {}): MockState {
|
||||
const documents = cloneArray(fixture.library?.documents);
|
||||
const defaultDocument: DocumentMetadata = {
|
||||
id: "qa-doc-1",
|
||||
title: "QA Document",
|
||||
kind: "text/plain",
|
||||
comments: "Seeded document for browser QA",
|
||||
tags: ["qa", "seed"],
|
||||
time: 1_700_000_000,
|
||||
user: "qa-user",
|
||||
"document-type": "source",
|
||||
documentType: "source",
|
||||
};
|
||||
if (documents.length === 0) documents.push(defaultDocument);
|
||||
|
||||
const metadataById: Record<string, DocumentMetadata> = {
|
||||
[defaultDocument.id ?? "qa-doc-1"]: defaultDocument,
|
||||
...(fixture.library?.metadataById ?? {}),
|
||||
};
|
||||
for (const document of documents) {
|
||||
if (document.id !== undefined) metadataById[document.id] = document;
|
||||
}
|
||||
|
||||
const triples = defaultTriples();
|
||||
return {
|
||||
settings: {
|
||||
user: fixture.settings?.user ?? "qa-user",
|
||||
apiKey: fixture.settings?.apiKey ?? "",
|
||||
gatewayUrl: fixture.settings?.gatewayUrl ?? "",
|
||||
collection: fixture.settings?.collection ?? "default",
|
||||
featureSwitches: {
|
||||
flowClasses: true,
|
||||
submissions: true,
|
||||
tokenCost: true,
|
||||
schemas: true,
|
||||
structuredQuery: true,
|
||||
ontologyEditor: true,
|
||||
agentTools: true,
|
||||
mcpTools: true,
|
||||
llmModels: true,
|
||||
...(fixture.settings?.featureSwitches ?? {}),
|
||||
},
|
||||
},
|
||||
flows: {
|
||||
activeIds: cloneArray(fixture.flows?.activeIds).length > 0 ? cloneArray(fixture.flows?.activeIds) : ["default", "qa-flow"],
|
||||
definitions: {
|
||||
default: { id: "default", description: "Default QA flow", class: "qa.default" },
|
||||
"qa-flow": { id: "qa-flow", description: "Seeded QA flow", class: "qa.seed" },
|
||||
...(fixture.flows?.definitions ?? {}),
|
||||
},
|
||||
blueprints: {
|
||||
"qa-blueprint": {
|
||||
name: "qa-blueprint",
|
||||
description: "Blueprint used by browser QA",
|
||||
parameters: { temperature: { type: "number", default: 0.1 } },
|
||||
},
|
||||
...(fixture.flows?.blueprints ?? {}),
|
||||
},
|
||||
},
|
||||
config: {
|
||||
prompt: {
|
||||
system: "You are the QA system prompt.",
|
||||
"qa-template": {
|
||||
system: "QA template system",
|
||||
prompt: "Answer the QA question: {{question}}",
|
||||
},
|
||||
...(fixture.config?.prompt ?? {}),
|
||||
},
|
||||
valuesByType: {
|
||||
...defaultConfigValues(),
|
||||
...(fixture.config?.valuesByType ?? {}),
|
||||
},
|
||||
},
|
||||
library: {
|
||||
documents,
|
||||
processing: cloneArray(fixture.library?.processing),
|
||||
metadataById,
|
||||
uploads: {},
|
||||
},
|
||||
knowledge: {
|
||||
kgCores: cloneArray(fixture.knowledge?.kgCores).length > 0 ? cloneArray(fixture.knowledge?.kgCores) : ["qa-core"],
|
||||
deCores: cloneArray(fixture.knowledge?.deCores).length > 0 ? cloneArray(fixture.knowledge?.deCores) : ["qa-de-core"],
|
||||
loadedKgCores: cloneArray(fixture.knowledge?.loadedKgCores),
|
||||
},
|
||||
collections: cloneArray(fixture.collections).length > 0
|
||||
? cloneArray(fixture.collections)
|
||||
: [{ id: "default", collection: "default", name: "default" }, { id: "qa-collection", collection: "qa-collection", name: "QA Collection" }],
|
||||
graph: {
|
||||
triplesByFlowCollection: {
|
||||
"default:default": triples,
|
||||
"qa-flow:default": triples,
|
||||
...(fixture.graph?.triplesByFlowCollection ?? {}),
|
||||
},
|
||||
explainTriplesByGraph: {
|
||||
"urn:qa-explain": triples.slice(0, 3),
|
||||
...(fixture.graph?.explainTriplesByGraph ?? {}),
|
||||
},
|
||||
},
|
||||
chat: {
|
||||
delayFrames: fixture.chat?.delayFrames ?? 0,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function configValues(state: MockState, type: string) {
|
||||
return Object.entries(state.config.valuesByType[type] ?? {}).map(([key, value]) => ({
|
||||
type,
|
||||
key,
|
||||
value: typeof value === "string" ? value : encodeJson(value),
|
||||
}));
|
||||
}
|
||||
|
||||
function addDocument(state: MockState, metadata: DocumentMetadata): DocumentMetadata {
|
||||
const id = metadata.id ?? `qa-doc-${state.library.documents.length + 1}`;
|
||||
const document = {
|
||||
...metadata,
|
||||
id,
|
||||
title: metadata.title ?? id,
|
||||
kind: metadata.kind ?? metadata["document-type"] ?? "text/plain",
|
||||
time: metadata.time ?? Math.floor(Date.now() / 1000),
|
||||
user: metadata.user ?? state.settings.user,
|
||||
tags: metadata.tags ?? [],
|
||||
};
|
||||
state.library.documents.push(document);
|
||||
state.library.metadataById[id] = document;
|
||||
return document;
|
||||
}
|
||||
|
||||
function dispatchRequest(state: MockState, service: string, request: Record<string, unknown>, flow: string | undefined): unknown {
|
||||
switch (service) {
|
||||
case "flow":
|
||||
return dispatchFlow(state, request);
|
||||
case "config":
|
||||
return dispatchConfig(state, request);
|
||||
case "librarian":
|
||||
return dispatchLibrarian(state, request);
|
||||
case "knowledge":
|
||||
return dispatchKnowledge(state, request);
|
||||
case "collection-management":
|
||||
return dispatchCollections(state, request);
|
||||
case "triples":
|
||||
return dispatchTriples(state, request, flow);
|
||||
default:
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function dispatchFlow(state: MockState, request: Record<string, unknown>): unknown {
|
||||
const operation = request.operation;
|
||||
if (operation === "list-flows") return { "flow-ids": [...state.flows.activeIds] };
|
||||
if (operation === "get-flow") {
|
||||
const id = stringValue(request["flow-id"], "default");
|
||||
return { flow: encodeJson(state.flows.definitions[id] ?? { id, description: "Mock flow" }) };
|
||||
}
|
||||
if (operation === "list-blueprints") return { "blueprint-names": Object.keys(state.flows.blueprints).sort() };
|
||||
if (operation === "get-blueprint") {
|
||||
const name = stringValue(request["blueprint-name"], "qa-blueprint");
|
||||
return { "blueprint-definition": encodeJson(state.flows.blueprints[name] ?? {}) };
|
||||
}
|
||||
if (operation === "start-flow") {
|
||||
const id = stringValue(request["flow-id"], `qa-flow-${state.flows.activeIds.length + 1}`);
|
||||
if (!state.flows.activeIds.includes(id)) state.flows.activeIds.push(id);
|
||||
state.flows.definitions[id] = {
|
||||
id,
|
||||
description: stringValue(request.description, "QA flow"),
|
||||
blueprint: stringValue(request["blueprint-name"], "qa-blueprint"),
|
||||
parameters: asRecord(request.parameters),
|
||||
};
|
||||
state.graph.triplesByFlowCollection[`${id}:default`] = defaultTriples();
|
||||
return {};
|
||||
}
|
||||
if (operation === "stop-flow") {
|
||||
const id = stringValue(request["flow-id"]);
|
||||
state.flows.activeIds.splice(0, state.flows.activeIds.length, ...state.flows.activeIds.filter((flowId) => flowId !== id));
|
||||
return {};
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
function dispatchConfig(state: MockState, request: Record<string, unknown>): unknown {
|
||||
const operation = request.operation;
|
||||
if (operation === "config") return { config: { prompt: state.config.prompt } };
|
||||
if (operation === "getvalues") return { values: configValues(state, stringValue(request.type)) };
|
||||
if (operation === "list") {
|
||||
const type = stringValue(request.type);
|
||||
return { keys: Object.keys(state.config.valuesByType[type] ?? {}) };
|
||||
}
|
||||
if (operation === "put" && Array.isArray(request.values)) {
|
||||
for (const value of request.values) {
|
||||
const item = asRecord(value);
|
||||
const type = stringValue(item.type);
|
||||
const key = stringValue(item.key);
|
||||
if (type.length > 0 && key.length > 0) {
|
||||
state.config.valuesByType[type] = state.config.valuesByType[type] ?? {};
|
||||
state.config.valuesByType[type][key] = item.value;
|
||||
}
|
||||
}
|
||||
return {};
|
||||
}
|
||||
if (operation === "delete" && Array.isArray(request.keys)) {
|
||||
for (const value of request.keys) {
|
||||
const item = asRecord(value);
|
||||
const type = stringValue(item.type);
|
||||
const key = stringValue(item.key);
|
||||
delete state.config.valuesByType[type]?.[key];
|
||||
}
|
||||
return {};
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
function dispatchLibrarian(state: MockState, request: Record<string, unknown>): unknown {
|
||||
const operation = request.operation;
|
||||
if (operation === "list-documents") return { "document-metadatas": [...state.library.documents] };
|
||||
if (operation === "list-processing") return { "processing-metadatas": [...state.library.processing] };
|
||||
if (operation === "get-document-metadata") {
|
||||
const id = stringValue(request["document-id"] ?? request.documentId);
|
||||
return { "document-metadata": state.library.metadataById[id] ?? null };
|
||||
}
|
||||
if (operation === "add-document") {
|
||||
addDocument(state, asRecord(request["document-metadata"] ?? request.documentMetadata) as DocumentMetadata);
|
||||
return {};
|
||||
}
|
||||
if (operation === "remove-document") {
|
||||
const id = stringValue(request["document-id"] ?? request.documentId);
|
||||
state.library.documents.splice(0, state.library.documents.length, ...state.library.documents.filter((document) => document.id !== id));
|
||||
delete state.library.metadataById[id];
|
||||
return {};
|
||||
}
|
||||
if (operation === "begin-upload") {
|
||||
const totalSize = numberValue(request["total-size"], 0);
|
||||
const chunkSize = Math.max(256_000, numberValue(request["chunk-size"], 512_000));
|
||||
const totalChunks = Math.max(1, Math.ceil(totalSize / chunkSize));
|
||||
const uploadId = `qa-upload-${Object.keys(state.library.uploads).length + 1}`;
|
||||
state.library.uploads[uploadId] = {
|
||||
metadata: asRecord(request["document-metadata"] ?? request.documentMetadata) as DocumentMetadata,
|
||||
chunks: [],
|
||||
totalSize,
|
||||
chunkSize,
|
||||
totalChunks,
|
||||
};
|
||||
return { "upload-id": uploadId, "chunk-size": chunkSize, "total-chunks": totalChunks };
|
||||
}
|
||||
if (operation === "upload-chunk") {
|
||||
const uploadId = stringValue(request["upload-id"]);
|
||||
const chunkIndex = numberValue(request["chunk-index"], 0);
|
||||
const upload = state.library.uploads[uploadId];
|
||||
if (upload !== undefined) upload.chunks[chunkIndex] = stringValue(request.content);
|
||||
return { "chunks-received": upload?.chunks.filter((chunk) => chunk !== undefined).length ?? 0 };
|
||||
}
|
||||
if (operation === "complete-upload") {
|
||||
const uploadId = stringValue(request["upload-id"]);
|
||||
const upload = state.library.uploads[uploadId];
|
||||
if (upload !== undefined) {
|
||||
addDocument(state, { ...upload.metadata, id: upload.metadata.id ?? uploadId });
|
||||
delete state.library.uploads[uploadId];
|
||||
}
|
||||
return { "document-id": uploadId, "object-id": uploadId };
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
function dispatchKnowledge(state: MockState, request: Record<string, unknown>): unknown {
|
||||
const operation = request.operation;
|
||||
const id = stringValue(request.id);
|
||||
if (operation === "list-kg-cores") return { ids: [...state.knowledge.kgCores] };
|
||||
if (operation === "list-de-cores") return { ids: [...state.knowledge.deCores] };
|
||||
if (operation === "load-kg-core") {
|
||||
if (id.length > 0 && !state.knowledge.loadedKgCores.includes(id)) state.knowledge.loadedKgCores.push(id);
|
||||
return {};
|
||||
}
|
||||
if (operation === "delete-kg-core") {
|
||||
state.knowledge.kgCores.splice(0, state.knowledge.kgCores.length, ...state.knowledge.kgCores.filter((core) => core !== id));
|
||||
return {};
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
function dispatchCollections(state: MockState, request: Record<string, unknown>): unknown {
|
||||
const operation = request.operation;
|
||||
const collection = stringValue(request.collection, "default");
|
||||
if (operation === "list-collections") return { collections: [...state.collections] };
|
||||
if (operation === "update-collection") {
|
||||
const entry = {
|
||||
id: collection,
|
||||
collection,
|
||||
name: stringValue(request.name, collection),
|
||||
description: stringValue(request.description),
|
||||
tags: Array.isArray(request.tags) ? request.tags : [],
|
||||
};
|
||||
state.collections.splice(0, state.collections.length, ...state.collections.filter((item) => item.collection !== collection && item.id !== collection), entry);
|
||||
return { collections: [entry] };
|
||||
}
|
||||
if (operation === "delete-collection") {
|
||||
state.collections.splice(0, state.collections.length, ...state.collections.filter((item) => item.collection !== collection && item.id !== collection));
|
||||
return {};
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
function dispatchTriples(state: MockState, request: Record<string, unknown>, flow: string | undefined): unknown {
|
||||
const graph = stringValue(request.g);
|
||||
const triples = graph.length > 0
|
||||
? state.graph.explainTriplesByGraph[graph] ?? []
|
||||
: state.graph.triplesByFlowCollection[graphKey(flow, request.collection)] ?? [];
|
||||
return { triples: triples.slice(0, numberValue(request.limit, triples.length)) };
|
||||
}
|
||||
|
||||
function streamResponses(service: string): Record<string, unknown>[] {
|
||||
const metadata: StreamingMetadata = { model: "qa-model", in_token: 12, out_token: 24 };
|
||||
if (service === "agent") {
|
||||
return [
|
||||
{ chunk_type: "thought", content: "Thinking about the QA request.", end_of_message: true },
|
||||
{ chunk_type: "observation", content: "Observed seeded graph and document context.", end_of_message: true },
|
||||
{ chunk_type: "answer", content: "Mock agent answer from TrustGraph.", end_of_message: true, end_of_dialog: true, ...metadata },
|
||||
];
|
||||
}
|
||||
if (service === "document-rag") {
|
||||
return [
|
||||
{ message_type: "explain", explain_id: "qa-document-explain", explain_graph: "urn:qa-explain" },
|
||||
{ response: "Mock document answer ", end_of_session: false },
|
||||
{ response: "from QA library.", end_of_session: true, ...metadata },
|
||||
];
|
||||
}
|
||||
return [
|
||||
{ message_type: "explain", explain_id: "qa-graph-explain", explain_graph: "urn:qa-explain" },
|
||||
{ response: "Mock graph answer ", end_of_session: false },
|
||||
{ response: "from QA knowledge graph.", end_of_session: true, ...metadata },
|
||||
];
|
||||
}
|
||||
|
||||
function scheduleFrames(frames: number, callback: () => void): void {
|
||||
const schedule = typeof requestAnimationFrame === "function" ? requestAnimationFrame : (fn: FrameRequestCallback) => queueMicrotask(() => fn(0));
|
||||
if (frames <= 0) {
|
||||
schedule(callback);
|
||||
} else {
|
||||
schedule(() => scheduleFrames(frames - 1, callback));
|
||||
}
|
||||
}
|
||||
|
||||
function dispatchStream<ResponseType>(
|
||||
state: MockState,
|
||||
service: string,
|
||||
receiver: (resp: unknown) => boolean,
|
||||
): Promise<ResponseType> {
|
||||
const responses = streamResponses(service);
|
||||
const emit = (index: number) => {
|
||||
const response = responses[index];
|
||||
if (response === undefined) return;
|
||||
const complete = index === responses.length - 1;
|
||||
const done = receiver({ response, complete });
|
||||
if (done !== true && !complete) {
|
||||
scheduleFrames(state.chat.delayFrames, () => emit(index + 1));
|
||||
}
|
||||
};
|
||||
scheduleFrames(state.chat.delayFrames, () => emit(0));
|
||||
return Promise.resolve({} as ResponseType);
|
||||
}
|
||||
|
||||
export function makeMockBaseApi(fixture: MockWorkbenchFixture = {}): BaseApi {
|
||||
const state = createState(fixture);
|
||||
const api = Object.create(BaseApi.prototype) as MockBaseApi;
|
||||
api.tag = "mock-workbench";
|
||||
api.id = 1;
|
||||
api.token = state.settings.apiKey.length > 0 ? state.settings.apiKey : undefined;
|
||||
api.user = state.settings.user;
|
||||
api.socketUrl = state.settings.gatewayUrl;
|
||||
api.makeRequest = function makeRequest<RequestType extends object, ResponseType>(
|
||||
service: string,
|
||||
request: RequestType,
|
||||
_timeout?: number,
|
||||
_retries?: number,
|
||||
flow?: string,
|
||||
) {
|
||||
return Promise.resolve(dispatchRequest(state, service, request as Record<string, unknown>, flow) as ResponseType);
|
||||
};
|
||||
api.makeRequestMulti = function makeRequestMulti<RequestType extends object, ResponseType>(
|
||||
service: string,
|
||||
_request: RequestType,
|
||||
receiver: (resp: unknown) => boolean,
|
||||
_timeout?: number,
|
||||
_retries?: number,
|
||||
_flow?: string,
|
||||
) {
|
||||
return dispatchStream<ResponseType>(state, service, receiver);
|
||||
};
|
||||
api.onConnectionStateChange = function onConnectionStateChange(listener: (state: ConnectionState) => void) {
|
||||
listener({
|
||||
status: api.token === undefined ? "unauthenticated" : "authenticated",
|
||||
hasApiKey: api.token !== undefined,
|
||||
});
|
||||
return () => {};
|
||||
};
|
||||
api.close = function close() {};
|
||||
return api;
|
||||
}
|
||||
|
||||
export function qaSettingsFromFixture(fixture: MockWorkbenchFixture = {}) {
|
||||
return createState(fixture).settings;
|
||||
}
|
||||
|
||||
export function decodeConfigFixtureValue(value: unknown): unknown {
|
||||
return decodeJson(value);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue