mirror of
https://github.com/trustgraph-ai/trustgraph.git
synced 2026-07-01 17:39:39 +02:00
init
This commit is contained in:
parent
c386f68743
commit
b6536eca38
100 changed files with 17680 additions and 377 deletions
13
ts/packages/workbench/index.html
Normal file
13
ts/packages/workbench/index.html
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>TrustGraph Workbench</title>
|
||||
</head>
|
||||
<body class="dark">
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
34
ts/packages/workbench/package.json
Normal file
34
ts/packages/workbench/package.json
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
{
|
||||
"name": "@trustgraph/workbench",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tanstack/react-query": "^5.75.0",
|
||||
"@trustgraph/client": "workspace:*",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.0",
|
||||
"lucide-react": "^0.513.0",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0",
|
||||
"react-force-graph-2d": "^1.29.1",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-router": "^7.6.0",
|
||||
"tailwind-merge": "^3.3.0",
|
||||
"zustand": "^5.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "^4.1.0",
|
||||
"@types/react": "^19.1.0",
|
||||
"@types/react-dom": "^19.1.0",
|
||||
"@vitejs/plugin-react": "^4.5.0",
|
||||
"tailwindcss": "^4.1.0",
|
||||
"typescript": "^5.8.0",
|
||||
"vite": "^6.3.0"
|
||||
}
|
||||
}
|
||||
27
ts/packages/workbench/src/App.tsx
Normal file
27
ts/packages/workbench/src/App.tsx
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
import { BrowserRouter, Routes, Route, Navigate } from "react-router";
|
||||
import { RootLayout } from "@/components/layout/root-layout";
|
||||
import ChatPage from "@/pages/chat";
|
||||
import LibraryPage from "@/pages/library";
|
||||
import GraphPage from "@/pages/graph";
|
||||
import FlowsPage from "@/pages/flows";
|
||||
import SettingsPage from "@/pages/settings";
|
||||
import { NotificationToasts } from "@/components/notification-toasts";
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
<Route element={<RootLayout />}>
|
||||
<Route index element={<Navigate to="/chat" replace />} />
|
||||
<Route path="/chat" element={<ChatPage />} />
|
||||
<Route path="/library" element={<LibraryPage />} />
|
||||
<Route path="/graph" element={<GraphPage />} />
|
||||
<Route path="/flows" element={<FlowsPage />} />
|
||||
<Route path="/settings" element={<SettingsPage />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
|
||||
<NotificationToasts />
|
||||
</BrowserRouter>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
import { Workflow, Database } from "lucide-react";
|
||||
import { useSessionStore } from "@/hooks/use-session-store";
|
||||
import { useSettings } from "@/providers/settings-provider";
|
||||
|
||||
/**
|
||||
* 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);
|
||||
|
||||
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">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<Database className="h-3.5 w-3.5" />
|
||||
{collection}
|
||||
</span>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<Workflow className="h-3.5 w-3.5" />
|
||||
{flowId || "<none>"}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
45
ts/packages/workbench/src/components/layout/root-layout.tsx
Normal file
45
ts/packages/workbench/src/components/layout/root-layout.tsx
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
import { Outlet } from "react-router";
|
||||
import { Sidebar } from "./sidebar";
|
||||
import { FlowSelector } from "./flow-selector";
|
||||
import { useProgressStore } from "@/hooks/use-progress-store";
|
||||
|
||||
/**
|
||||
* Top loading bar -- shown when any global activity is in progress.
|
||||
*/
|
||||
function LoadingBar() {
|
||||
const isLoading = useProgressStore((s) => s.isLoading);
|
||||
|
||||
if (!isLoading) return null;
|
||||
|
||||
return (
|
||||
<div className="absolute left-0 right-0 top-0 z-40 h-0.5 overflow-hidden bg-surface-200">
|
||||
<div className="h-full w-1/3 animate-[slide_1.2s_ease-in-out_infinite] bg-brand-500" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Root layout: fixed sidebar + scrollable main content area with a top bar.
|
||||
*/
|
||||
export function RootLayout() {
|
||||
return (
|
||||
<div className="relative flex h-screen w-full overflow-hidden bg-surface-0">
|
||||
{/* Global loading bar */}
|
||||
<LoadingBar />
|
||||
|
||||
<Sidebar />
|
||||
|
||||
<div className="flex flex-1 flex-col overflow-hidden">
|
||||
{/* Top bar */}
|
||||
<header className="flex h-14 shrink-0 items-center justify-end border-b border-border bg-surface-50 px-6">
|
||||
<FlowSelector />
|
||||
</header>
|
||||
|
||||
{/* Page content */}
|
||||
<main className="flex-1 overflow-y-auto p-6">
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
168
ts/packages/workbench/src/components/layout/sidebar.tsx
Normal file
168
ts/packages/workbench/src/components/layout/sidebar.tsx
Normal file
|
|
@ -0,0 +1,168 @@
|
|||
import { NavLink } from "react-router";
|
||||
import {
|
||||
MessageSquareText,
|
||||
LibraryBig,
|
||||
Rotate3d,
|
||||
Workflow,
|
||||
Settings,
|
||||
TestTube2,
|
||||
Wifi,
|
||||
WifiOff,
|
||||
Database,
|
||||
ChevronDown,
|
||||
} from "lucide-react";
|
||||
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";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Nav item
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface NavItemProps {
|
||||
to: string;
|
||||
icon: React.ElementType;
|
||||
label: string;
|
||||
}
|
||||
|
||||
function NavItem({ to, icon: Icon, label }: NavItemProps) {
|
||||
return (
|
||||
<NavLink to={to} className="w-full">
|
||||
{({ isActive }) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium transition-colors",
|
||||
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>
|
||||
</div>
|
||||
)}
|
||||
</NavLink>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Connection status badge
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function ConnectionBadge() {
|
||||
const state = useConnectionState();
|
||||
|
||||
const isConnected =
|
||||
state.status === "connected" ||
|
||||
state.status === "authenticated" ||
|
||||
state.status === "unauthenticated";
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-2 rounded-lg px-3 py-2 text-xs font-medium",
|
||||
isConnected ? "text-success" : "text-fg-subtle",
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"h-2 w-2 shrink-0 rounded-full",
|
||||
isConnected ? "bg-success animate-pulse" : "bg-fg-subtle",
|
||||
)}
|
||||
/>
|
||||
{isConnected ? (
|
||||
<Wifi className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<WifiOff className="h-3.5 w-3.5" />
|
||||
)}
|
||||
<span className="truncate capitalize">{state.status}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Flow selector dropdown
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function FlowSelectorDropdown() {
|
||||
const { flows } = useFlows();
|
||||
const flowId = useSessionStore((s) => s.flowId);
|
||||
const setFlowId = useSessionStore((s) => s.setFlowId);
|
||||
const collection = useSettings((s) => s.settings.collection);
|
||||
|
||||
return (
|
||||
<div className="space-y-2 px-3">
|
||||
{/* Flow selector */}
|
||||
<div className="space-y-1">
|
||||
<label className="flex items-center gap-1.5 text-[10px] font-medium uppercase tracking-wider text-fg-subtle">
|
||||
<Workflow className="h-3 w-3" />
|
||||
Flow
|
||||
</label>
|
||||
<div className="relative">
|
||||
<select
|
||||
value={flowId}
|
||||
onChange={(e) => setFlowId(e.target.value)}
|
||||
className="w-full appearance-none rounded-md border border-border bg-surface-100 py-1.5 pl-2.5 pr-7 text-xs 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}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<ChevronDown className="pointer-events-none absolute right-2 top-1/2 h-3 w-3 -translate-y-1/2 text-fg-subtle" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Collection badge */}
|
||||
<div className="flex items-center gap-1.5 rounded-md bg-surface-100 px-2.5 py-1.5 text-xs text-fg-muted">
|
||||
<Database className="h-3 w-3 shrink-0 text-fg-subtle" />
|
||||
<span className="truncate">{collection}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sidebar
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function Sidebar() {
|
||||
return (
|
||||
<aside className="flex h-screen w-sidebar shrink-0 flex-col border-r border-border bg-surface-50">
|
||||
{/* Logo area */}
|
||||
<div className="flex h-14 items-center gap-2 px-4">
|
||||
<TestTube2 className="h-5 w-5 text-brand-500" />
|
||||
<span className="text-lg font-bold text-fg">TrustGraph</span>
|
||||
</div>
|
||||
|
||||
{/* Divider */}
|
||||
<div className="mx-3 border-t border-border" />
|
||||
|
||||
{/* Flow & collection selectors */}
|
||||
<div className="py-3">
|
||||
<FlowSelectorDropdown />
|
||||
</div>
|
||||
|
||||
{/* Divider */}
|
||||
<div className="mx-3 border-t border-border" />
|
||||
|
||||
{/* Navigation links */}
|
||||
<nav className="flex flex-1 flex-col gap-0.5 overflow-y-auto px-2 py-3">
|
||||
<NavItem to="/chat" icon={MessageSquareText} label="Chat" />
|
||||
<NavItem to="/library" icon={LibraryBig} label="Library" />
|
||||
<NavItem to="/graph" icon={Rotate3d} label="Graph" />
|
||||
<NavItem to="/flows" icon={Workflow} label="Flows" />
|
||||
<NavItem to="/settings" icon={Settings} label="Settings" />
|
||||
</nav>
|
||||
|
||||
{/* Footer: connection badge */}
|
||||
<div className="border-t border-border px-2 py-2">
|
||||
<ConnectionBadge />
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
47
ts/packages/workbench/src/components/notification-toasts.tsx
Normal file
47
ts/packages/workbench/src/components/notification-toasts.tsx
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
import { X } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useNotification, type NotificationType } from "@/providers/notification-provider";
|
||||
|
||||
const typeStyles: Record<NotificationType, 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",
|
||||
info: "border-brand-500/40 bg-brand-500/10 text-brand-300",
|
||||
};
|
||||
|
||||
/**
|
||||
* 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);
|
||||
|
||||
if (notifications.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed bottom-4 right-4 z-50 flex flex-col gap-2">
|
||||
{notifications.map((n) => (
|
||||
<div
|
||||
key={n.id}
|
||||
className={cn(
|
||||
"flex items-start gap-2 rounded-lg border px-4 py-3 text-sm shadow-lg",
|
||||
typeStyles[n.type],
|
||||
)}
|
||||
>
|
||||
<div className="flex-1">
|
||||
<p className="font-medium">{n.title}</p>
|
||||
{n.description && (
|
||||
<p className="mt-0.5 text-xs opacity-80">{n.description}</p>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => removeNotification(n.id)}
|
||||
className="shrink-0 opacity-60 hover:opacity-100"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
31
ts/packages/workbench/src/components/ui/badge.tsx
Normal file
31
ts/packages/workbench/src/components/ui/badge.tsx
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
import { cn } from "@/lib/utils";
|
||||
|
||||
type BadgeVariant = "default" | "success" | "warning" | "error" | "info";
|
||||
|
||||
const variantStyles: Record<BadgeVariant, string> = {
|
||||
default: "border-border bg-surface-200 text-fg-muted",
|
||||
success: "border-success/30 bg-success/10 text-success",
|
||||
warning: "border-warning/30 bg-warning/10 text-warning",
|
||||
error: "border-error/30 bg-error/10 text-error",
|
||||
info: "border-brand-500/30 bg-brand-500/10 text-brand-300",
|
||||
};
|
||||
|
||||
interface BadgeProps {
|
||||
children: React.ReactNode;
|
||||
variant?: BadgeVariant;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function Badge({ children, variant = "default", className }: BadgeProps) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-xs font-medium",
|
||||
variantStyles[variant],
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
85
ts/packages/workbench/src/components/ui/dialog.tsx
Normal file
85
ts/packages/workbench/src/components/ui/dialog.tsx
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
import {
|
||||
type ReactNode,
|
||||
type MouseEvent,
|
||||
useCallback,
|
||||
useEffect,
|
||||
} from "react";
|
||||
import { X } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface DialogProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
title: string;
|
||||
children: ReactNode;
|
||||
footer?: ReactNode;
|
||||
/** Max width class, defaults to max-w-lg */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Simple modal dialog built with Tailwind.
|
||||
* Renders a backdrop overlay + centered content panel.
|
||||
*/
|
||||
export function Dialog({
|
||||
open,
|
||||
onClose,
|
||||
title,
|
||||
children,
|
||||
footer,
|
||||
className,
|
||||
}: DialogProps) {
|
||||
// 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]);
|
||||
|
||||
const handleBackdrop = useCallback(
|
||||
(e: MouseEvent) => {
|
||||
if (e.target === e.currentTarget) onClose();
|
||||
},
|
||||
[onClose],
|
||||
);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm"
|
||||
onClick={handleBackdrop}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"relative w-full max-w-lg rounded-xl border border-border bg-surface-100 shadow-2xl",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between border-b border-border px-6 py-4">
|
||||
<h2 className="text-lg font-semibold text-fg">{title}</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="rounded-md p-1 text-fg-subtle hover:bg-surface-200 hover:text-fg"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="max-h-[60vh] overflow-y-auto px-6 py-4">{children}</div>
|
||||
|
||||
{/* Footer */}
|
||||
{footer && (
|
||||
<div className="flex items-center justify-end gap-2 border-t border-border px-6 py-4">
|
||||
{footer}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
42
ts/packages/workbench/src/components/ui/tabs.tsx
Normal file
42
ts/packages/workbench/src/components/ui/tabs.tsx
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface TabItem {
|
||||
value: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface TabsProps {
|
||||
items: TabItem[];
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Minimal segmented-control / tab bar.
|
||||
*/
|
||||
export function Tabs({ items, value, onChange, className }: TabsProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex rounded-lg border border-border bg-surface-100 p-0.5",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{items.map((item) => (
|
||||
<button
|
||||
key={item.value}
|
||||
onClick={() => onChange(item.value)}
|
||||
className={cn(
|
||||
"rounded-md px-3 py-1.5 text-xs font-medium transition-colors",
|
||||
value === item.value
|
||||
? "bg-brand-600 text-white"
|
||||
: "text-fg-muted hover:text-fg",
|
||||
)}
|
||||
>
|
||||
{item.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
48
ts/packages/workbench/src/components/ui/textarea.tsx
Normal file
48
ts/packages/workbench/src/components/ui/textarea.tsx
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
import { useRef, useEffect, type TextareaHTMLAttributes } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface AutoTextareaProps
|
||||
extends TextareaHTMLAttributes<HTMLTextAreaElement> {
|
||||
/** Maximum number of rows before scrolling */
|
||||
maxRows?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Textarea that auto-resizes to fit its content, up to maxRows.
|
||||
*/
|
||||
export function AutoTextarea({
|
||||
maxRows = 6,
|
||||
className,
|
||||
value,
|
||||
...props
|
||||
}: AutoTextareaProps) {
|
||||
const ref = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const el = ref.current;
|
||||
if (!el) 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]);
|
||||
|
||||
return (
|
||||
<textarea
|
||||
ref={ref}
|
||||
value={value}
|
||||
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",
|
||||
className,
|
||||
)}
|
||||
rows={1}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
215
ts/packages/workbench/src/hooks/use-chat.ts
Normal file
215
ts/packages/workbench/src/hooks/use-chat.ts
Normal file
|
|
@ -0,0 +1,215 @@
|
|||
import { useCallback } 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 } from "@trustgraph/client";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Hook
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface UseChatReturn {
|
||||
submitMessage: (opts: { input: string }) => 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 submitMessage = useCallback(
|
||||
({ input }: { input: string }) => {
|
||||
if (!input.trim()) return;
|
||||
|
||||
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);
|
||||
|
||||
// Shared handler for streaming responses (graph-rag / document-rag)
|
||||
const onChunk = (
|
||||
chunk: string,
|
||||
complete: boolean,
|
||||
metadata?: StreamingMetadata,
|
||||
) => {
|
||||
updateLastMessage((prev) => ({
|
||||
...prev,
|
||||
content: prev.content + chunk,
|
||||
isStreaming: !complete,
|
||||
...(complete && metadata
|
||||
? {
|
||||
metadata: {
|
||||
model: metadata.model,
|
||||
inTokens: metadata.in_token,
|
||||
outTokens: metadata.out_token,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
}));
|
||||
|
||||
if (complete) {
|
||||
removeActivity(activityLabel);
|
||||
}
|
||||
};
|
||||
|
||||
const onError = (error: string) => {
|
||||
updateLastMessage((prev) => ({
|
||||
...prev,
|
||||
content: 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);
|
||||
break;
|
||||
|
||||
case "document-rag":
|
||||
flow.documentRagStreaming(input, onChunk, onError, undefined, collection);
|
||||
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,
|
||||
},
|
||||
activePhase: complete ? prev.activePhase : "think",
|
||||
};
|
||||
});
|
||||
},
|
||||
// observe
|
||||
(chunk, complete) => {
|
||||
updateLastMessage((prev) => {
|
||||
const phases = prev.agentPhases ?? {
|
||||
think: "",
|
||||
observe: "",
|
||||
answer: "",
|
||||
};
|
||||
return {
|
||||
...prev,
|
||||
agentPhases: {
|
||||
...phases,
|
||||
observe: phases.observe + chunk,
|
||||
},
|
||||
activePhase: complete ? prev.activePhase : "observe",
|
||||
};
|
||||
});
|
||||
},
|
||||
// answer
|
||||
(chunk, complete, metadata) => {
|
||||
updateLastMessage((prev) => {
|
||||
const phases = prev.agentPhases ?? {
|
||||
think: "",
|
||||
observe: "",
|
||||
answer: "",
|
||||
};
|
||||
const newAnswer = phases.answer + chunk;
|
||||
return {
|
||||
...prev,
|
||||
content: newAnswer,
|
||||
agentPhases: {
|
||||
...phases,
|
||||
answer: newAnswer,
|
||||
},
|
||||
activePhase: complete ? undefined : "answer",
|
||||
isStreaming: !complete,
|
||||
...(complete && metadata
|
||||
? {
|
||||
metadata: {
|
||||
model: metadata.model,
|
||||
inTokens: metadata.in_token,
|
||||
outTokens: metadata.out_token,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
});
|
||||
if (complete) {
|
||||
removeActivity(activityLabel);
|
||||
}
|
||||
},
|
||||
// error
|
||||
onError,
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
},
|
||||
[
|
||||
socket,
|
||||
flowId,
|
||||
chatMode,
|
||||
collection,
|
||||
addMessage,
|
||||
updateLastMessage,
|
||||
setInput,
|
||||
addActivity,
|
||||
removeActivity,
|
||||
],
|
||||
);
|
||||
|
||||
return { submitMessage };
|
||||
}
|
||||
91
ts/packages/workbench/src/hooks/use-conversation.ts
Normal file
91
ts/packages/workbench/src/hooks/use-conversation.ts
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
import { create } from "zustand";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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;
|
||||
|
||||
clearMessages: () => void;
|
||||
}
|
||||
|
||||
let _nextMsgId = 0;
|
||||
export function nextMessageId(): string {
|
||||
return `msg-${++_nextMsgId}-${Date.now()}`;
|
||||
}
|
||||
|
||||
export const useConversation = create<ConversationState>()((set) => ({
|
||||
messages: [],
|
||||
input: "",
|
||||
chatMode: "graph-rag",
|
||||
|
||||
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],
|
||||
};
|
||||
}),
|
||||
|
||||
clearMessages: () => set({ messages: [] }),
|
||||
}));
|
||||
130
ts/packages/workbench/src/hooks/use-flows.ts
Normal file
130
ts/packages/workbench/src/hooks/use-flows.ts
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
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 };
|
||||
}
|
||||
134
ts/packages/workbench/src/hooks/use-library.ts
Normal file
134
ts/packages/workbench/src/hooks/use-library.ts
Normal file
|
|
@ -0,0 +1,134 @@
|
|||
import { useCallback, useState } from "react";
|
||||
import { useSocket } from "@/providers/socket-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 UseLibraryReturn {
|
||||
documents: DocumentMetadata[];
|
||||
processing: ProcessingMetadata[];
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
|
||||
/** Refresh the documents list */
|
||||
getDocuments: () => Promise<void>;
|
||||
/** Upload a new document */
|
||||
uploadDocument: (
|
||||
document: string,
|
||||
mimeType: string,
|
||||
title: string,
|
||||
comments: string,
|
||||
tags: string[],
|
||||
id?: string,
|
||||
) => Promise<void>;
|
||||
/** Remove a document */
|
||||
removeDocument: (id: string, collection?: string) => Promise<void>;
|
||||
/** Get the list of currently-processing documents */
|
||||
getProcessing: () => Promise<void>;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Hook
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function useLibrary(): UseLibraryReturn {
|
||||
const socket = useSocket();
|
||||
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 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]);
|
||||
|
||||
return {
|
||||
documents,
|
||||
processing,
|
||||
loading,
|
||||
error,
|
||||
getDocuments,
|
||||
uploadDocument,
|
||||
removeDocument,
|
||||
getProcessing,
|
||||
};
|
||||
}
|
||||
39
ts/packages/workbench/src/hooks/use-progress-store.ts
Normal file
39
ts/packages/workbench/src/hooks/use-progress-store.ts
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
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 };
|
||||
}),
|
||||
}));
|
||||
34
ts/packages/workbench/src/hooks/use-session-store.ts
Normal file
34
ts/packages/workbench/src/hooks/use-session-store.ts
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
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 }),
|
||||
}));
|
||||
126
ts/packages/workbench/src/index.css
Normal file
126
ts/packages/workbench/src/index.css
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
@import "tailwindcss";
|
||||
|
||||
/*
|
||||
* TrustGraph Workbench -- Dark-mode-first design tokens
|
||||
*
|
||||
* Tailwind CSS v4 uses CSS-first configuration.
|
||||
* Custom theme values are declared as CSS custom properties.
|
||||
*/
|
||||
|
||||
@theme {
|
||||
/* Brand palette */
|
||||
--color-brand-50: #eef2ff;
|
||||
--color-brand-100: #dce4ff;
|
||||
--color-brand-200: #b9c9ff;
|
||||
--color-brand-300: #8aa5ff;
|
||||
--color-brand-400: #5b80ff;
|
||||
--color-brand-500: #3b63ed;
|
||||
--color-brand-600: #2d4ec4;
|
||||
--color-brand-700: #213a9b;
|
||||
--color-brand-800: #162872;
|
||||
--color-brand-900: #0e1a4d;
|
||||
|
||||
/* Surface / background colors (dark-first) */
|
||||
--color-surface-0: #09090b;
|
||||
--color-surface-50: #111113;
|
||||
--color-surface-100: #18181b;
|
||||
--color-surface-200: #27272a;
|
||||
--color-surface-300: #3f3f46;
|
||||
--color-surface-400: #52525b;
|
||||
|
||||
/* Foreground / text colors */
|
||||
--color-fg: #fafafa;
|
||||
--color-fg-muted: #a1a1aa;
|
||||
--color-fg-subtle: #71717a;
|
||||
|
||||
/* Border colors */
|
||||
--color-border: #27272a;
|
||||
--color-border-hover: #3f3f46;
|
||||
|
||||
/* Semantic: success / warning / error */
|
||||
--color-success: #22c55e;
|
||||
--color-warning: #eab308;
|
||||
--color-error: #ef4444;
|
||||
|
||||
/* Sidebar width */
|
||||
--spacing-sidebar: 16rem;
|
||||
--spacing-sidebar-collapsed: 4rem;
|
||||
|
||||
/* Font families */
|
||||
--font-sans: "Inter", ui-sans-serif, system-ui, sans-serif;
|
||||
--font-mono: "JetBrains Mono", ui-monospace, monospace;
|
||||
}
|
||||
|
||||
/* Base layer: dark background, light text by default */
|
||||
@layer base {
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
border-color: var(--color-border);
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: var(--color-surface-0);
|
||||
color: var(--color-fg);
|
||||
font-family: var(--font-sans);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
/* Scrollbar styling for dark mode */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: var(--color-surface-100);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: var(--color-surface-300);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--color-surface-400);
|
||||
}
|
||||
}
|
||||
|
||||
/* Loading bar slide animation */
|
||||
@keyframes slide {
|
||||
0% {
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
100% {
|
||||
transform: translateX(400%);
|
||||
}
|
||||
}
|
||||
|
||||
/* Prose overrides for dark mode markdown rendering */
|
||||
@layer base {
|
||||
.prose-invert code {
|
||||
color: var(--color-brand-300);
|
||||
}
|
||||
|
||||
.prose-invert pre {
|
||||
background: var(--color-surface-200);
|
||||
}
|
||||
}
|
||||
|
||||
/* Light mode overrides (activated by .light class on <html>) */
|
||||
html.light {
|
||||
--color-surface-0: #ffffff;
|
||||
--color-surface-50: #fafafa;
|
||||
--color-surface-100: #f4f4f5;
|
||||
--color-surface-200: #e4e4e7;
|
||||
--color-surface-300: #d4d4d8;
|
||||
--color-surface-400: #a1a1aa;
|
||||
|
||||
--color-fg: #18181b;
|
||||
--color-fg-muted: #52525b;
|
||||
--color-fg-subtle: #71717a;
|
||||
|
||||
--color-border: #e4e4e7;
|
||||
--color-border-hover: #d4d4d8;
|
||||
}
|
||||
6
ts/packages/workbench/src/lib/utils.ts
Normal file
6
ts/packages/workbench/src/lib/utils.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
import { type ClassValue, clsx } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
36
ts/packages/workbench/src/main.tsx
Normal file
36
ts/packages/workbench/src/main.tsx
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import App from "@/App";
|
||||
import { SocketProvider } from "@/providers/socket-provider";
|
||||
import { useSettings } from "@/providers/settings-provider";
|
||||
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);
|
||||
|
||||
return (
|
||||
<SocketProvider
|
||||
user={settings.user}
|
||||
apiKey={settings.apiKey || undefined}
|
||||
socketUrl={settings.gatewayUrl || undefined}
|
||||
>
|
||||
<App />
|
||||
</SocketProvider>
|
||||
);
|
||||
}
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||
<React.StrictMode>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<AppRoot />
|
||||
</QueryClientProvider>
|
||||
</React.StrictMode>,
|
||||
);
|
||||
305
ts/packages/workbench/src/pages/chat.tsx
Normal file
305
ts/packages/workbench/src/pages/chat.tsx
Normal file
|
|
@ -0,0 +1,305 @@
|
|||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
type KeyboardEvent,
|
||||
} from "react";
|
||||
import {
|
||||
MessageSquareText,
|
||||
Send,
|
||||
Trash2,
|
||||
Brain,
|
||||
Eye,
|
||||
CheckCircle,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Loader2,
|
||||
} 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 { AutoTextarea } from "@/components/ui/textarea";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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({
|
||||
phase,
|
||||
icon,
|
||||
label,
|
||||
content,
|
||||
isActive,
|
||||
}: {
|
||||
phase: string;
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
content: string;
|
||||
isActive: boolean;
|
||||
}) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
if (!content && !isActive) return null;
|
||||
|
||||
const phaseColors: Record<string, string> = {
|
||||
think: "border-amber-500/30 bg-amber-500/5",
|
||||
observe: "border-sky-500/30 bg-sky-500/5",
|
||||
answer: "border-emerald-500/30 bg-emerald-500/5",
|
||||
};
|
||||
|
||||
const badgeColors: Record<string, string> = {
|
||||
think: "bg-amber-500/20 text-amber-400",
|
||||
observe: "bg-sky-500/20 text-sky-400",
|
||||
answer: "bg-emerald-500/20 text-emerald-400",
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-md border",
|
||||
phaseColors[phase] ?? "border-border bg-surface-100",
|
||||
)}
|
||||
>
|
||||
<button
|
||||
onClick={() => setExpanded((p) => !p)}
|
||||
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" />
|
||||
)}
|
||||
{icon}
|
||||
<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" />
|
||||
)}
|
||||
</button>
|
||||
{expanded && content && (
|
||||
<div className="border-t border-border/50 px-3 py-2 text-xs leading-relaxed text-fg-muted">
|
||||
<p className="whitespace-pre-wrap">{content}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Single message bubble
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function MessageBubble({ msg }: { msg: ChatMessage }) {
|
||||
const isUser = msg.role === "user";
|
||||
const hasAgentPhases = msg.agentPhases != null;
|
||||
|
||||
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"
|
||||
: "mr-auto max-w-[80%] bg-surface-100 text-fg",
|
||||
)}
|
||||
>
|
||||
{/* Agent phase blocks (only for agent messages) */}
|
||||
{hasAgentPhases && msg.agentPhases && (
|
||||
<div className="mb-2 space-y-1.5">
|
||||
<AgentPhaseBlock
|
||||
phase="think"
|
||||
icon={<Brain className="h-3 w-3" />}
|
||||
label="Thinking"
|
||||
content={msg.agentPhases.think}
|
||||
isActive={msg.activePhase === "think"}
|
||||
/>
|
||||
<AgentPhaseBlock
|
||||
phase="observe"
|
||||
icon={<Eye className="h-3 w-3" />}
|
||||
label="Observing"
|
||||
content={msg.agentPhases.observe}
|
||||
isActive={msg.activePhase === "observe"}
|
||||
/>
|
||||
{msg.agentPhases.answer && (
|
||||
<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>
|
||||
) : (
|
||||
<div className="prose prose-invert prose-sm max-w-none prose-p:my-1 prose-pre:bg-surface-200 prose-pre:text-fg prose-code:text-brand-300">
|
||||
<Markdown>{msg.content || (msg.isStreaming ? "" : "(empty)")}</Markdown>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Streaming indicator */}
|
||||
{msg.isStreaming && (
|
||||
<span className="mt-1 inline-block h-2 w-2 animate-pulse rounded-full bg-brand-400" />
|
||||
)}
|
||||
|
||||
{/* Token metadata */}
|
||||
{msg.metadata && (
|
||||
<div className="mt-2 flex items-center gap-3 text-[10px] text-fg-subtle">
|
||||
{msg.metadata.model && <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>
|
||||
)}
|
||||
</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 } = useChat();
|
||||
const collection = useSettings((s) => s.settings.collection);
|
||||
const isLoading = useProgressStore((s) => s.isLoading);
|
||||
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Auto-scroll to bottom when messages change
|
||||
useEffect(() => {
|
||||
scrollRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||
}, [messages]);
|
||||
|
||||
const handleSubmit = useCallback(() => {
|
||||
if (input.trim()) {
|
||||
submitMessage({ input });
|
||||
}
|
||||
}, [input, submitMessage]);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSubmit();
|
||||
}
|
||||
},
|
||||
[handleSubmit],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
{/* Header */}
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<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-subtle">
|
||||
{collection}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Mode selector */}
|
||||
<div className="flex rounded-lg border border-border bg-surface-100 p-0.5">
|
||||
{MODES.map((mode) => (
|
||||
<button
|
||||
key={mode.value}
|
||||
onClick={() => setChatMode(mode.value)}
|
||||
className={cn(
|
||||
"rounded-md px-3 py-1 text-xs font-medium transition-colors",
|
||||
chatMode === mode.value
|
||||
? "bg-brand-600 text-white"
|
||||
: "text-fg-muted hover:text-fg",
|
||||
)}
|
||||
>
|
||||
{mode.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={clearMessages}
|
||||
className="rounded-lg p-2 text-fg-subtle hover:bg-surface-200 hover:text-fg"
|
||||
title="Clear messages"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Messages */}
|
||||
<div className="flex-1 space-y-4 overflow-y-auto pb-4">
|
||||
{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">{chatMode}</span>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{messages.map((msg) => (
|
||||
<MessageBubble key={msg.id} msg={msg} />
|
||||
))}
|
||||
<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...</span>
|
||||
</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)"
|
||||
maxRows={6}
|
||||
/>
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={!input.trim() || isLoading}
|
||||
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>
|
||||
);
|
||||
}
|
||||
490
ts/packages/workbench/src/pages/flows.tsx
Normal file
490
ts/packages/workbench/src/pages/flows.tsx
Normal file
|
|
@ -0,0 +1,490 @@
|
|||
import { useCallback, useEffect, useState } from "react";
|
||||
import {
|
||||
Workflow,
|
||||
Plus,
|
||||
Square,
|
||||
RefreshCw,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Loader2,
|
||||
AlertTriangle,
|
||||
} 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 { Dialog } from "@/components/ui/dialog";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Start flow dialog
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
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);
|
||||
|
||||
// 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) {
|
||||
setBlueprint(list[0]!);
|
||||
}
|
||||
})
|
||||
.catch(() => setBlueprints([]))
|
||||
.finally(() => setLoadingBlueprints(false));
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [open, socket]);
|
||||
|
||||
const reset = () => {
|
||||
setId("");
|
||||
setBlueprint("");
|
||||
setDescription("");
|
||||
setParamsJson("{}");
|
||||
setParamsError(null);
|
||||
setSubmitting(false);
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
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();
|
||||
}
|
||||
}}
|
||||
title="Start Flow"
|
||||
footer={
|
||||
<>
|
||||
<button
|
||||
onClick={() => {
|
||||
reset();
|
||||
onClose();
|
||||
}}
|
||||
disabled={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={!isValid || 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" />}
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
Start
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{/* Flow ID */}
|
||||
<div className="mb-3 space-y-1.5">
|
||||
<label className="block text-sm font-medium text-fg-muted">
|
||||
Flow ID <span className="text-error">*</span>
|
||||
</label>
|
||||
<input
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Blueprint name */}
|
||||
<div className="mb-3 space-y-1.5">
|
||||
<label 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
|
||||
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>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<div className="mb-3 space-y-1.5">
|
||||
<label className="block text-sm font-medium text-fg-muted">
|
||||
Description <span className="text-error">*</span>
|
||||
</label>
|
||||
<input
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Parameters (JSON) */}
|
||||
<div className="space-y-1.5">
|
||||
<label className="block text-sm font-medium text-fg-muted">
|
||||
Parameters (JSON)
|
||||
</label>
|
||||
<textarea
|
||||
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
|
||||
? "border-error focus:border-error focus:ring-error"
|
||||
: "border-border focus:border-brand-500 focus:ring-brand-500",
|
||||
)}
|
||||
/>
|
||||
{paramsError && (
|
||||
<p className="text-xs text-error">{paramsError}</p>
|
||||
)}
|
||||
</div>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Stop flow confirm dialog
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function StopFlowDialog({
|
||||
open,
|
||||
flowId,
|
||||
onClose,
|
||||
onConfirm,
|
||||
}: {
|
||||
open: boolean;
|
||||
flowId: string;
|
||||
onClose: () => void;
|
||||
onConfirm: () => void;
|
||||
}) {
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
title="Stop Flow"
|
||||
footer={
|
||||
<>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="rounded-lg border border-border px-4 py-2 text-sm text-fg-muted hover:bg-surface-200"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={onConfirm}
|
||||
className="rounded-lg bg-error px-4 py-2 text-sm font-medium text-white hover:opacity-90"
|
||||
>
|
||||
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 || "--"}
|
||||
</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"
|
||||
>
|
||||
<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) 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);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
{/* Header */}
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<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-subtle">
|
||||
{flows.length} active
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => getFlows()}
|
||||
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"
|
||||
>
|
||||
<RefreshCw className={cn("h-3.5 w-3.5", loading && "animate-spin")} />
|
||||
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"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
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" />
|
||||
<span className="text-fg-subtle">Loading flows...</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<p className="mb-4 rounded-lg bg-error/10 px-4 py-2 text-sm text-error">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{!loading && !error && 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>
|
||||
</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>
|
||||
)}
|
||||
|
||||
{/* Dialogs */}
|
||||
<StartFlowDialog
|
||||
open={createOpen}
|
||||
onClose={() => setCreateOpen(false)}
|
||||
onStart={handleStart}
|
||||
/>
|
||||
|
||||
<StopFlowDialog
|
||||
open={stopTarget != null}
|
||||
flowId={stopTarget ?? ""}
|
||||
onClose={() => setStopTarget(null)}
|
||||
onConfirm={handleStop}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
586
ts/packages/workbench/src/pages/graph.tsx
Normal file
586
ts/packages/workbench/src/pages/graph.tsx
Normal file
|
|
@ -0,0 +1,586 @@
|
|||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import {
|
||||
Rotate3d,
|
||||
Search,
|
||||
ZoomIn,
|
||||
ZoomOut,
|
||||
Maximize,
|
||||
Loader2,
|
||||
X,
|
||||
ArrowRight,
|
||||
ArrowLeft,
|
||||
} 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";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Lazy-load ForceGraph2D to keep bundle size down
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// react-force-graph-2d ships a default export
|
||||
import ForceGraph2D, {
|
||||
type ForceGraphMethods,
|
||||
type NodeObject,
|
||||
type LinkObject,
|
||||
} from "react-force-graph-2d";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface GraphNode extends NodeObject {
|
||||
id: string;
|
||||
label: string;
|
||||
color?: string;
|
||||
/** Number of connections (used for sizing) */
|
||||
degree: number;
|
||||
}
|
||||
|
||||
interface GraphLink extends LinkObject {
|
||||
source: string;
|
||||
target: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface GraphData {
|
||||
nodes: GraphNode[];
|
||||
links: GraphLink[];
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers -- Term value extraction
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const RDFS_LABEL = "http://www.w3.org/2000/01/rdf-schema#label";
|
||||
const RDF_TYPE = "http://www.w3.org/1999/02/22-rdf-syntax-ns#type";
|
||||
|
||||
function termValue(t: Term): string {
|
||||
switch (t.t) {
|
||||
case "i":
|
||||
return t.i;
|
||||
case "l":
|
||||
return t.v;
|
||||
case "b":
|
||||
return t.d;
|
||||
case "t":
|
||||
return "[triple]";
|
||||
}
|
||||
}
|
||||
|
||||
function isIri(t: Term): boolean {
|
||||
return t.t === "i";
|
||||
}
|
||||
|
||||
/** Extract the local name from a URI for display */
|
||||
function localName(uri: string): string {
|
||||
const hash = uri.lastIndexOf("#");
|
||||
const slash = uri.lastIndexOf("/");
|
||||
const idx = Math.max(hash, slash);
|
||||
if (idx >= 0 && idx < uri.length - 1) return uri.substring(idx + 1);
|
||||
return uri;
|
||||
}
|
||||
|
||||
/** Deterministic color from a string (for node types) */
|
||||
function hashColor(s: string): string {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < s.length; i++) {
|
||||
hash = s.charCodeAt(i) + ((hash << 5) - hash);
|
||||
}
|
||||
const hue = ((hash % 360) + 360) % 360;
|
||||
return `hsl(${hue}, 60%, 55%)`;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Build graph data from triples
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function triplesToGraph(triples: Triple[]): {
|
||||
data: GraphData;
|
||||
labelMap: Map<string, string>;
|
||||
typeMap: Map<string, string>;
|
||||
} {
|
||||
const labelMap = new Map<string, string>();
|
||||
const typeMap = new Map<string, string>();
|
||||
|
||||
// First pass: collect labels and types
|
||||
for (const t of triples) {
|
||||
const pred = termValue(t.p);
|
||||
if (pred === RDFS_LABEL && t.o.t === "l") {
|
||||
labelMap.set(termValue(t.s), t.o.v);
|
||||
}
|
||||
if (pred === RDF_TYPE && isIri(t.o)) {
|
||||
typeMap.set(termValue(t.s), termValue(t.o));
|
||||
}
|
||||
}
|
||||
|
||||
// Second pass: build nodes and links (skip structural triples)
|
||||
const nodeMap = new Map<string, GraphNode>();
|
||||
const links: GraphLink[] = [];
|
||||
|
||||
const ensureNode = (uri: string): void => {
|
||||
if (!nodeMap.has(uri)) {
|
||||
const type = typeMap.get(uri);
|
||||
nodeMap.set(uri, {
|
||||
id: uri,
|
||||
label: labelMap.get(uri) ?? localName(uri),
|
||||
color: type ? hashColor(localName(type)) : "#5b80ff",
|
||||
degree: 0,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
for (const t of triples) {
|
||||
const sVal = termValue(t.s);
|
||||
const pVal = termValue(t.p);
|
||||
const oVal = termValue(t.o);
|
||||
|
||||
// Skip label and type predicates -- they are metadata, not graph edges
|
||||
if (pVal === RDFS_LABEL) continue;
|
||||
if (pVal === RDF_TYPE) continue;
|
||||
|
||||
// Only build edges when both endpoints are IRIs (entity-to-entity)
|
||||
if (!isIri(t.s) || !isIri(t.o)) continue;
|
||||
|
||||
ensureNode(sVal);
|
||||
ensureNode(oVal);
|
||||
nodeMap.get(sVal)!.degree++;
|
||||
nodeMap.get(oVal)!.degree++;
|
||||
|
||||
links.push({
|
||||
source: sVal,
|
||||
target: oVal,
|
||||
label: labelMap.get(pVal) ?? localName(pVal),
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
data: { nodes: Array.from(nodeMap.values()), links },
|
||||
labelMap,
|
||||
typeMap,
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Node detail panel
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function NodeDetailPanel({
|
||||
nodeId,
|
||||
label,
|
||||
triples,
|
||||
labelMap,
|
||||
onClose,
|
||||
}: {
|
||||
nodeId: string;
|
||||
label: string;
|
||||
triples: Triple[];
|
||||
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 }[] = [];
|
||||
|
||||
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),
|
||||
});
|
||||
}
|
||||
}
|
||||
return { outbound, inbound };
|
||||
}, [nodeId, triples, labelMap]);
|
||||
|
||||
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"
|
||||
>
|
||||
<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>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 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>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{related.outbound.length === 0 && related.inbound.length === 0 && (
|
||||
<p className="text-xs text-fg-subtle">No relationships found.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Graph page
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
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);
|
||||
|
||||
const fgRef = useRef<ForceGraphMethods<GraphNode, GraphLink> | undefined>(
|
||||
undefined,
|
||||
);
|
||||
|
||||
// Fetch triples
|
||||
const fetchTriples = useCallback(async () => {
|
||||
const act = "Load graph";
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
addActivity(act);
|
||||
|
||||
const flow = socket.flow(flowId);
|
||||
const result = await flow.triplesQuery(
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
2000,
|
||||
collection,
|
||||
);
|
||||
setTriples(result);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
removeActivity(act);
|
||||
}
|
||||
}, [socket, flowId, collection, addActivity, removeActivity]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchTriples();
|
||||
}, [fetchTriples]);
|
||||
|
||||
// Build graph
|
||||
const { data: graphData, labelMap } = useMemo(
|
||||
() => triplesToGraph(triples),
|
||||
[triples],
|
||||
);
|
||||
|
||||
// Search filter -- highlight matching nodes
|
||||
const searchLower = searchTerm.toLowerCase();
|
||||
const matchingIds = useMemo(() => {
|
||||
if (!searchLower) 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
|
||||
? labelMap.get(selectedNode) ?? localName(selectedNode)
|
||||
: "";
|
||||
|
||||
// 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
|
||||
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;
|
||||
|
||||
// Node circle
|
||||
ctx.beginPath();
|
||||
ctx.arc(x, y, radius, 0, 2 * Math.PI);
|
||||
ctx.fillStyle = dim
|
||||
? "rgba(100,100,100,0.3)"
|
||||
: isSelected
|
||||
? "#fbbf24"
|
||||
: isMatch
|
||||
? "#22c55e"
|
||||
: node.color ?? "#5b80ff";
|
||||
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 = `${fontSize}px Inter, sans-serif`;
|
||||
ctx.textAlign = "center";
|
||||
ctx.textBaseline = "top";
|
||||
ctx.fillStyle = dim ? "rgba(100,100,100,0.3)" : "rgba(250,250,250,0.9)";
|
||||
ctx.fillText(node.label, x, y + radius + 1);
|
||||
},
|
||||
[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 || !tgt.x) 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);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
{/* Header */}
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<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-subtle">
|
||||
{graphData.nodes.length} nodes, {graphData.links.length} edges
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex 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" />
|
||||
<input
|
||||
type="text"
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
placeholder="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"
|
||||
/>
|
||||
{searchTerm && (
|
||||
<button
|
||||
onClick={() => setSearchTerm("")}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 text-fg-subtle hover:text-fg"
|
||||
>
|
||||
<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"
|
||||
>
|
||||
<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"
|
||||
>
|
||||
<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"
|
||||
>
|
||||
<Maximize className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={fetchTriples}
|
||||
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"
|
||||
>
|
||||
{loading ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<Rotate3d className="h-3.5 w-3.5" />
|
||||
)}
|
||||
Reload
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
{error && (
|
||||
<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>
|
||||
)}
|
||||
|
||||
{!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>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{graphData.nodes.length > 0 && (
|
||||
<div className="flex flex-1 overflow-hidden rounded-lg border border-border">
|
||||
{/* Graph canvas */}
|
||||
<div className="relative flex-1 bg-surface-0">
|
||||
<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();
|
||||
}}
|
||||
linkCanvasObjectMode={() => "after"}
|
||||
linkCanvasObject={paintLink}
|
||||
linkColor={() => "rgba(91,128,255,0.25)"}
|
||||
linkDirectionalArrowLength={4}
|
||||
linkDirectionalArrowRelPos={0.85}
|
||||
onNodeClick={(node: GraphNode) => {
|
||||
setSelectedNode((prev) =>
|
||||
prev === node.id ? null : node.id,
|
||||
);
|
||||
}}
|
||||
onBackgroundClick={() => setSelectedNode(null)}
|
||||
backgroundColor="transparent"
|
||||
width={undefined}
|
||||
height={undefined}
|
||||
/>
|
||||
|
||||
{/* Search results badge overlay */}
|
||||
{searchTerm && matchingIds.size > 0 && (
|
||||
<div className="absolute bottom-3 left-3">
|
||||
<Badge variant="success">
|
||||
{matchingIds.size} match{matchingIds.size > 1 ? "es" : ""}
|
||||
</Badge>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Detail panel */}
|
||||
{selectedNode && (
|
||||
<NodeDetailPanel
|
||||
nodeId={selectedNode}
|
||||
label={selectedLabel}
|
||||
triples={triples}
|
||||
labelMap={labelMap}
|
||||
onClose={() => setSelectedNode(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
486
ts/packages/workbench/src/pages/library.tsx
Normal file
486
ts/packages/workbench/src/pages/library.tsx
Normal file
|
|
@ -0,0 +1,486 @@
|
|||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
LibraryBig,
|
||||
Upload,
|
||||
Trash2,
|
||||
RefreshCw,
|
||||
FileText,
|
||||
FileType2,
|
||||
Loader2,
|
||||
X,
|
||||
AlertTriangle,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useLibrary } from "@/hooks/use-library";
|
||||
import { useSettings } from "@/providers/settings-provider";
|
||||
import { useNotification } from "@/providers/notification-provider";
|
||||
import { Dialog } from "@/components/ui/dialog";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import type { DocumentMetadata } from "@trustgraph/client";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Upload dialog
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function UploadDialog({
|
||||
open,
|
||||
onClose,
|
||||
onUpload,
|
||||
}: {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onUpload: (
|
||||
data: string,
|
||||
mimeType: string,
|
||||
title: string,
|
||||
comments: string,
|
||||
tags: string[],
|
||||
) => Promise<void>;
|
||||
}) {
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [title, setTitle] = useState("");
|
||||
const [tags, setTags] = useState("");
|
||||
const [comments, setComments] = useState("");
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [dragOver, setDragOver] = useState(false);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const reset = () => {
|
||||
setFile(null);
|
||||
setTitle("");
|
||||
setTags("");
|
||||
setComments("");
|
||||
setUploading(false);
|
||||
};
|
||||
|
||||
const handleFile = (f: File) => {
|
||||
setFile(f);
|
||||
if (!title) setTitle(f.name.replace(/\.[^/.]+$/, ""));
|
||||
};
|
||||
|
||||
const handleDrop = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
setDragOver(false);
|
||||
const f = e.dataTransfer.files[0];
|
||||
if (f) handleFile(f);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!file) return;
|
||||
setUploading(true);
|
||||
try {
|
||||
const base64 = await fileToBase64(file);
|
||||
const tagList = tags
|
||||
.split(",")
|
||||
.map((t) => t.trim())
|
||||
.filter(Boolean);
|
||||
await onUpload(base64, file.type || "application/octet-stream", title, comments, tagList);
|
||||
reset();
|
||||
onClose();
|
||||
} catch {
|
||||
setUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onClose={() => {
|
||||
if (!uploading) {
|
||||
reset();
|
||||
onClose();
|
||||
}
|
||||
}}
|
||||
title="Upload Document"
|
||||
footer={
|
||||
<>
|
||||
<button
|
||||
onClick={() => {
|
||||
reset();
|
||||
onClose();
|
||||
}}
|
||||
disabled={uploading}
|
||||
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={!file || !title.trim() || uploading}
|
||||
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"
|
||||
>
|
||||
{uploading && <Loader2 className="h-3.5 w-3.5 animate-spin" />}
|
||||
Upload
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{/* Drop zone */}
|
||||
<div
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault();
|
||||
setDragOver(true);
|
||||
}}
|
||||
onDragLeave={() => setDragOver(false)}
|
||||
onDrop={handleDrop}
|
||||
onClick={() => inputRef.current?.click()}
|
||||
className={cn(
|
||||
"mb-4 flex cursor-pointer flex-col items-center justify-center rounded-lg border-2 border-dashed px-6 py-8 transition-colors",
|
||||
dragOver
|
||||
? "border-brand-500 bg-brand-500/10"
|
||||
: "border-border hover:border-border-hover",
|
||||
)}
|
||||
>
|
||||
<Upload className="mb-2 h-8 w-8 text-fg-subtle" />
|
||||
{file ? (
|
||||
<div className="flex items-center gap-2 text-sm text-fg">
|
||||
<FileText className="h-4 w-4" />
|
||||
<span>{file.name}</span>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setFile(null);
|
||||
}}
|
||||
className="ml-1 text-fg-subtle hover:text-fg"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<p className="text-sm text-fg-muted">
|
||||
Drop a file here or click to browse
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-fg-subtle">PDF, TXT, or other text formats</p>
|
||||
</>
|
||||
)}
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
className="hidden"
|
||||
accept=".pdf,.txt,.md,.csv,.json,.xml,.html"
|
||||
onChange={(e) => {
|
||||
const f = e.target.files?.[0];
|
||||
if (f) handleFile(f);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Title */}
|
||||
<div className="mb-3 space-y-1.5">
|
||||
<label className="block text-sm font-medium text-fg-muted">Title</label>
|
||||
<input
|
||||
type="text"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder="Document title"
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Comments */}
|
||||
<div className="mb-3 space-y-1.5">
|
||||
<label className="block text-sm font-medium text-fg-muted">Comments</label>
|
||||
<input
|
||||
type="text"
|
||||
value={comments}
|
||||
onChange={(e) => setComments(e.target.value)}
|
||||
placeholder="Optional comments"
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Tags */}
|
||||
<div className="space-y-1.5">
|
||||
<label className="block text-sm font-medium text-fg-muted">Tags</label>
|
||||
<input
|
||||
type="text"
|
||||
value={tags}
|
||||
onChange={(e) => setTags(e.target.value)}
|
||||
placeholder="Comma-separated tags"
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Confirm delete dialog
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function ConfirmDeleteDialog({
|
||||
open,
|
||||
docTitle,
|
||||
onClose,
|
||||
onConfirm,
|
||||
}: {
|
||||
open: boolean;
|
||||
docTitle: string;
|
||||
onClose: () => void;
|
||||
onConfirm: () => void;
|
||||
}) {
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
title="Delete Document"
|
||||
footer={
|
||||
<>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="rounded-lg border border-border px-4 py-2 text-sm text-fg-muted hover:bg-surface-200"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={onConfirm}
|
||||
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">
|
||||
<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{" "}
|
||||
<span className="font-medium text-fg">{docTitle || "this document"}</span>?
|
||||
This action cannot be undone.
|
||||
</p>
|
||||
</div>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Library page
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export default function LibraryPage() {
|
||||
const {
|
||||
documents,
|
||||
processing,
|
||||
loading,
|
||||
error,
|
||||
getDocuments,
|
||||
uploadDocument,
|
||||
removeDocument,
|
||||
getProcessing,
|
||||
} = useLibrary();
|
||||
const collection = useSettings((s) => s.settings.collection);
|
||||
const notify = useNotification();
|
||||
|
||||
const [uploadOpen, setUploadOpen] = useState(false);
|
||||
const [deleteTarget, setDeleteTarget] = useState<DocumentMetadata | null>(null);
|
||||
|
||||
// Load documents and processing on mount
|
||||
useEffect(() => {
|
||||
getDocuments();
|
||||
getProcessing();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const handleUpload = async (
|
||||
data: string,
|
||||
mimeType: string,
|
||||
title: string,
|
||||
comments: string,
|
||||
tags: string[],
|
||||
) => {
|
||||
try {
|
||||
await uploadDocument(data, mimeType, title, comments, tags);
|
||||
notify.success("Document uploaded", `"${title}" is being processed.`);
|
||||
getProcessing();
|
||||
} catch {
|
||||
notify.error("Upload failed", "Could not upload the document.");
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!deleteTarget?.id) return;
|
||||
try {
|
||||
await removeDocument(deleteTarget.id, collection);
|
||||
notify.success("Document deleted");
|
||||
} catch {
|
||||
notify.error("Delete failed");
|
||||
}
|
||||
setDeleteTarget(null);
|
||||
};
|
||||
|
||||
const handleRefresh = () => {
|
||||
getDocuments();
|
||||
getProcessing();
|
||||
};
|
||||
|
||||
const guessKind = (doc: DocumentMetadata): string => {
|
||||
const kind = doc.kind ?? doc["document-type"] ?? "";
|
||||
if (kind.includes("pdf")) return "PDF";
|
||||
if (kind.includes("text") || kind.includes("plain")) return "Text";
|
||||
if (kind.includes("html")) return "HTML";
|
||||
if (kind.includes("json")) return "JSON";
|
||||
return kind || "--";
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
{/* Header */}
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<LibraryBig className="h-6 w-6 text-brand-400" />
|
||||
<h1 className="text-2xl font-bold text-fg">Library</h1>
|
||||
<span className="ml-2 rounded bg-surface-200 px-2 py-0.5 text-xs text-fg-subtle">
|
||||
{collection}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={handleRefresh}
|
||||
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"
|
||||
>
|
||||
<RefreshCw className={cn("h-3.5 w-3.5", loading && "animate-spin")} />
|
||||
Refresh
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setUploadOpen(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"
|
||||
>
|
||||
<Upload className="h-4 w-4" />
|
||||
Upload
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Processing status */}
|
||||
{processing.length > 0 && (
|
||||
<div className="mb-4 rounded-lg border border-brand-500/30 bg-brand-500/5 p-3">
|
||||
<div className="mb-2 flex items-center gap-2 text-sm font-medium text-brand-300">
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
Processing ({processing.length})
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
{processing.map((p) => (
|
||||
<div key={p.id} className="flex items-center gap-2 text-xs text-fg-muted">
|
||||
<FileType2 className="h-3 w-3" />
|
||||
<span className="truncate">{p["document-id"] || p.id}</span>
|
||||
<Badge variant="info" className="ml-auto">
|
||||
{p.flow || "processing"}
|
||||
</Badge>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Content */}
|
||||
{loading && documents.length === 0 && (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="mr-2 h-5 w-5 animate-spin text-fg-subtle" />
|
||||
<span className="text-fg-subtle">Loading documents...</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<p className="py-8 text-center text-error">Error: {error}</p>
|
||||
)}
|
||||
|
||||
{!loading && !error && documents.length === 0 && (
|
||||
<div className="flex flex-1 flex-col items-center justify-center">
|
||||
<LibraryBig className="mb-3 h-10 w-10 text-fg-subtle opacity-30" />
|
||||
<p className="text-fg-subtle">
|
||||
No documents yet. Upload one to get started.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{documents.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">Title</th>
|
||||
<th className="px-4 py-3 font-medium">Type</th>
|
||||
<th className="px-4 py-3 font-medium">Tags</th>
|
||||
<th className="px-4 py-3 font-medium">ID</th>
|
||||
<th className="px-4 py-3 font-medium text-right">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{documents.map((doc) => (
|
||||
<tr key={doc.id} className="hover:bg-surface-100/50">
|
||||
<td className="px-4 py-3 text-fg">
|
||||
<div className="flex items-center gap-2">
|
||||
<FileText className="h-4 w-4 shrink-0 text-fg-subtle" />
|
||||
{doc.title || "Untitled"}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<Badge variant="default">{guessKind(doc)}</Badge>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{(doc.tags ?? []).map((tag) => (
|
||||
<Badge key={tag} variant="info">{tag}</Badge>
|
||||
))}
|
||||
{(!doc.tags || doc.tags.length === 0) && (
|
||||
<span className="text-fg-subtle">--</span>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="max-w-[12rem] truncate px-4 py-3 font-mono text-xs text-fg-subtle">
|
||||
{doc.id}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
<button
|
||||
onClick={() => setDeleteTarget(doc)}
|
||||
className="rounded p-1.5 text-fg-subtle hover:bg-error/10 hover:text-error"
|
||||
title="Delete document"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Dialogs */}
|
||||
<UploadDialog
|
||||
open={uploadOpen}
|
||||
onClose={() => setUploadOpen(false)}
|
||||
onUpload={handleUpload}
|
||||
/>
|
||||
|
||||
<ConfirmDeleteDialog
|
||||
open={deleteTarget != null}
|
||||
docTitle={deleteTarget?.title ?? deleteTarget?.id ?? ""}
|
||||
onClose={() => setDeleteTarget(null)}
|
||||
onConfirm={handleDelete}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function fileToBase64(file: File): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
const result = reader.result as string;
|
||||
// Strip the data URL prefix (e.g. "data:application/pdf;base64,")
|
||||
const base64 = result.includes(",") ? result.split(",")[1]! : result;
|
||||
resolve(base64);
|
||||
};
|
||||
reader.onerror = reject;
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
}
|
||||
340
ts/packages/workbench/src/pages/settings.tsx
Normal file
340
ts/packages/workbench/src/pages/settings.tsx
Normal file
|
|
@ -0,0 +1,340 @@
|
|||
import { useCallback, useEffect, useState } from "react";
|
||||
import {
|
||||
Settings as SettingsIcon,
|
||||
Wifi,
|
||||
WifiOff,
|
||||
Key,
|
||||
Eye,
|
||||
EyeOff,
|
||||
Database,
|
||||
Workflow,
|
||||
Info,
|
||||
Loader2,
|
||||
Moon,
|
||||
Sun,
|
||||
} 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 { Badge } from "@/components/ui/badge";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Section wrapper
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function Section({
|
||||
title,
|
||||
icon,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
icon: React.ReactNode;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="rounded-lg border border-border bg-surface-50 p-5">
|
||||
<h2 className="mb-4 flex items-center gap-2 text-sm font-semibold text-fg">
|
||||
{icon}
|
||||
{title}
|
||||
</h2>
|
||||
<div className="space-y-4">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Settings page
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export default function SettingsPage() {
|
||||
const { settings, updateSetting } = useSettings();
|
||||
const connectionState = useConnectionState();
|
||||
const socket = useSocket();
|
||||
const { flows } = useFlows();
|
||||
const notify = useNotification();
|
||||
|
||||
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);
|
||||
|
||||
// Dark mode toggle -- uses a class on <html> and persists to localStorage
|
||||
const [isDark, setIsDark] = useState(() => {
|
||||
if (typeof window === "undefined") return true;
|
||||
return !document.documentElement.classList.contains("light");
|
||||
});
|
||||
|
||||
const toggleTheme = useCallback(() => {
|
||||
const next = !isDark;
|
||||
setIsDark(next);
|
||||
if (next) {
|
||||
document.documentElement.classList.remove("light");
|
||||
localStorage.setItem("tg-theme", "dark");
|
||||
} else {
|
||||
document.documentElement.classList.add("light");
|
||||
localStorage.setItem("tg-theme", "light");
|
||||
}
|
||||
}, [isDark]);
|
||||
|
||||
// Fetch collections
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setLoadingCollections(true);
|
||||
socket
|
||||
.collectionManagement()
|
||||
.listCollections()
|
||||
.then((cols) => {
|
||||
if (!cancelled) {
|
||||
setCollections(
|
||||
Array.isArray(cols)
|
||||
? (cols as Array<{ id?: string; name?: string; [key: string]: unknown }>)
|
||||
: [],
|
||||
);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
/* silent -- collections endpoint may not be available */
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoadingCollections(false);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [socket]);
|
||||
|
||||
// Connection status helpers
|
||||
const isConnected =
|
||||
connectionState.status === "connected" ||
|
||||
connectionState.status === "authenticated" ||
|
||||
connectionState.status === "unauthenticated";
|
||||
|
||||
const statusBadge = isConnected ? (
|
||||
<Badge variant="success">
|
||||
<Wifi className="h-3 w-3" /> {connectionState.status}
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="error">
|
||||
<WifiOff className="h-3 w-3" /> {connectionState.status}
|
||||
</Badge>
|
||||
);
|
||||
|
||||
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">
|
||||
{/* 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>
|
||||
{statusBadge}
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label className="block text-sm font-medium text-fg-muted">
|
||||
Gateway URL
|
||||
</label>
|
||||
<input
|
||||
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 TrustGraph gateway.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label className="block text-sm font-medium text-fg-muted">
|
||||
User ID
|
||||
</label>
|
||||
<input
|
||||
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 className="block text-sm font-medium text-fg-muted">
|
||||
API Key
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
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"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowApiKey((p) => !p)}
|
||||
className="absolute right-3 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" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-xs text-fg-subtle">
|
||||
Changing the API key will reconnect the WebSocket.
|
||||
</p>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{/* Collection */}
|
||||
<Section
|
||||
title="Collection"
|
||||
icon={<Database className="h-4 w-4 text-fg-subtle" />}
|
||||
>
|
||||
<div className="space-y-1.5">
|
||||
<label 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 ? (
|
||||
<select
|
||||
value={settings.collection}
|
||||
onChange={(e) => updateSetting("collection", e.target.value)}
|
||||
className="w-full rounded-lg border border-border bg-surface-100 px-4 py-2 text-sm text-fg focus:border-brand-500 focus:outline-none focus:ring-1 focus:ring-brand-500"
|
||||
>
|
||||
{collections.map((c) => {
|
||||
const id = c.id ?? String(c.name ?? c);
|
||||
return (
|
||||
<option key={id} value={id}>
|
||||
{c.name ?? id}
|
||||
</option>
|
||||
);
|
||||
})}
|
||||
</select>
|
||||
) : (
|
||||
<input
|
||||
type="text"
|
||||
value={settings.collection}
|
||||
onChange={(e) => updateSetting("collection", e.target.value)}
|
||||
className="w-full rounded-lg border border-border bg-surface-100 px-4 py-2 text-sm text-fg placeholder:text-fg-subtle focus:border-brand-500 focus:outline-none focus:ring-1 focus:ring-brand-500"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{/* Flow */}
|
||||
<Section
|
||||
title="Active Flow"
|
||||
icon={<Workflow className="h-4 w-4 text-fg-subtle" />}
|
||||
>
|
||||
<div className="space-y-1.5">
|
||||
<label className="block text-sm font-medium text-fg-muted">
|
||||
Flow
|
||||
</label>
|
||||
{flows.length > 0 ? (
|
||||
<select
|
||||
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"
|
||||
>
|
||||
<option value="default">default</option>
|
||||
{flows.map((f) => (
|
||||
<option key={f.id} value={f.id}>
|
||||
{f.id}
|
||||
{f.description ? ` -- ${f.description}` : ""}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
) : (
|
||||
<input
|
||||
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>
|
||||
</div>
|
||||
<button
|
||||
onClick={toggleTheme}
|
||||
className={cn(
|
||||
"relative inline-flex h-6 w-11 items-center rounded-full transition-colors",
|
||||
isDark ? "bg-brand-600" : "bg-surface-300",
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"inline-block h-4 w-4 rounded-full bg-white transition-transform",
|
||||
isDark ? "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">TrustGraph Workbench</span>{" "}
|
||||
v0.1.0
|
||||
</p>
|
||||
<p>
|
||||
A web-based interface for interacting with the TrustGraph
|
||||
knowledge-graph system.
|
||||
</p>
|
||||
</div>
|
||||
</Section>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,92 @@
|
|||
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 };
|
||||
|
||||
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),
|
||||
};
|
||||
});
|
||||
110
ts/packages/workbench/src/providers/settings-provider.tsx
Normal file
110
ts/packages/workbench/src/providers/settings-provider.tsx
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
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: "user",
|
||||
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,
|
||||
|
||||
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) state.isLoaded = true;
|
||||
},
|
||||
},
|
||||
),
|
||||
);
|
||||
125
ts/packages/workbench/src/providers/socket-provider.tsx
Normal file
125
ts/packages/workbench/src/providers/socket-provider.tsx
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
import {
|
||||
createContext,
|
||||
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) 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) {
|
||||
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,
|
||||
});
|
||||
|
||||
const subscribe = (onStoreChange: () => void) => {
|
||||
return api.onConnectionStateChange((next) => {
|
||||
stateRef.current = next;
|
||||
onStoreChange();
|
||||
});
|
||||
};
|
||||
|
||||
const getSnapshot = () => stateRef.current;
|
||||
|
||||
return useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
|
||||
}
|
||||
18
ts/packages/workbench/tsconfig.json
Normal file
18
ts/packages/workbench/tsconfig.json
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"jsx": "react-jsx",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
},
|
||||
"noEmit": true,
|
||||
"composite": true
|
||||
},
|
||||
"include": ["src", "vite-env.d.ts"]
|
||||
}
|
||||
1
ts/packages/workbench/vite-env.d.ts
vendored
Normal file
1
ts/packages/workbench/vite-env.d.ts
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
/// <reference types="vite/client" />
|
||||
25
ts/packages/workbench/vite.config.ts
Normal file
25
ts/packages/workbench/vite.config.ts
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import tailwindcss from "@tailwindcss/vite";
|
||||
import path from "path";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react(), tailwindcss()],
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": path.resolve(__dirname, "./src"),
|
||||
},
|
||||
},
|
||||
server: {
|
||||
proxy: {
|
||||
"/api/socket": {
|
||||
target: "ws://localhost:8088/",
|
||||
ws: true,
|
||||
rewrite: (p) => p.replace("/api/socket", "/api/v1/socket"),
|
||||
},
|
||||
"/api/v1": {
|
||||
target: "http://localhost:8088/",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue