This commit is contained in:
elpresidank 2026-04-05 22:44:45 -05:00
parent c386f68743
commit b6536eca38
100 changed files with 17680 additions and 377 deletions

View 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>
);
}

View file

@ -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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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}
/>
);
}

View 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 };
}

View 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: [] }),
}));

View 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 };
}

View 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,
};
}

View 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 };
}),
}));

View 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 }),
}));

View 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;
}

View 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));
}

View 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>,
);

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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);
});
}

View 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>
);
}

View file

@ -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),
};
});

View 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;
},
},
),
);

View 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);
}