mirror of
https://github.com/trustgraph-ai/trustgraph.git
synced 2026-07-16 00:31:02 +02:00
init
This commit is contained in:
parent
c386f68743
commit
b6536eca38
100 changed files with 17680 additions and 377 deletions
305
ts/packages/workbench/src/pages/chat.tsx
Normal file
305
ts/packages/workbench/src/pages/chat.tsx
Normal file
|
|
@ -0,0 +1,305 @@
|
|||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
type KeyboardEvent,
|
||||
} from "react";
|
||||
import {
|
||||
MessageSquareText,
|
||||
Send,
|
||||
Trash2,
|
||||
Brain,
|
||||
Eye,
|
||||
CheckCircle,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Loader2,
|
||||
} from "lucide-react";
|
||||
import Markdown from "react-markdown";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useConversation, type ChatMessage } from "@/hooks/use-conversation";
|
||||
import { useChat } from "@/hooks/use-chat";
|
||||
import { useSettings } from "@/providers/settings-provider";
|
||||
import { useProgressStore } from "@/hooks/use-progress-store";
|
||||
import { AutoTextarea } from "@/components/ui/textarea";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const MODES = [
|
||||
{ value: "graph-rag" as const, label: "Graph RAG" },
|
||||
{ value: "document-rag" as const, label: "Doc RAG" },
|
||||
{ value: "agent" as const, label: "Agent" },
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Agent phase section (collapsible)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function AgentPhaseBlock({
|
||||
phase,
|
||||
icon,
|
||||
label,
|
||||
content,
|
||||
isActive,
|
||||
}: {
|
||||
phase: string;
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
content: string;
|
||||
isActive: boolean;
|
||||
}) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
if (!content && !isActive) return null;
|
||||
|
||||
const phaseColors: Record<string, string> = {
|
||||
think: "border-amber-500/30 bg-amber-500/5",
|
||||
observe: "border-sky-500/30 bg-sky-500/5",
|
||||
answer: "border-emerald-500/30 bg-emerald-500/5",
|
||||
};
|
||||
|
||||
const badgeColors: Record<string, string> = {
|
||||
think: "bg-amber-500/20 text-amber-400",
|
||||
observe: "bg-sky-500/20 text-sky-400",
|
||||
answer: "bg-emerald-500/20 text-emerald-400",
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-md border",
|
||||
phaseColors[phase] ?? "border-border bg-surface-100",
|
||||
)}
|
||||
>
|
||||
<button
|
||||
onClick={() => setExpanded((p) => !p)}
|
||||
className="flex w-full items-center gap-2 px-3 py-2 text-left text-xs font-medium text-fg-muted"
|
||||
>
|
||||
{expanded ? (
|
||||
<ChevronDown className="h-3 w-3 shrink-0" />
|
||||
) : (
|
||||
<ChevronRight className="h-3 w-3 shrink-0" />
|
||||
)}
|
||||
{icon}
|
||||
<span
|
||||
className={cn(
|
||||
"rounded px-1.5 py-0.5",
|
||||
badgeColors[phase] ?? "bg-surface-200 text-fg-muted",
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
{isActive && (
|
||||
<Loader2 className="ml-auto h-3 w-3 animate-spin text-fg-subtle" />
|
||||
)}
|
||||
</button>
|
||||
{expanded && content && (
|
||||
<div className="border-t border-border/50 px-3 py-2 text-xs leading-relaxed text-fg-muted">
|
||||
<p className="whitespace-pre-wrap">{content}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Single message bubble
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function MessageBubble({ msg }: { msg: ChatMessage }) {
|
||||
const isUser = msg.role === "user";
|
||||
const hasAgentPhases = msg.agentPhases != null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-lg px-4 py-3 text-sm leading-relaxed",
|
||||
isUser
|
||||
? "ml-auto max-w-[80%] bg-brand-700/30 text-fg"
|
||||
: "mr-auto max-w-[80%] bg-surface-100 text-fg",
|
||||
)}
|
||||
>
|
||||
{/* Agent phase blocks (only for agent messages) */}
|
||||
{hasAgentPhases && msg.agentPhases && (
|
||||
<div className="mb-2 space-y-1.5">
|
||||
<AgentPhaseBlock
|
||||
phase="think"
|
||||
icon={<Brain className="h-3 w-3" />}
|
||||
label="Thinking"
|
||||
content={msg.agentPhases.think}
|
||||
isActive={msg.activePhase === "think"}
|
||||
/>
|
||||
<AgentPhaseBlock
|
||||
phase="observe"
|
||||
icon={<Eye className="h-3 w-3" />}
|
||||
label="Observing"
|
||||
content={msg.agentPhases.observe}
|
||||
isActive={msg.activePhase === "observe"}
|
||||
/>
|
||||
{msg.agentPhases.answer && (
|
||||
<div className="flex items-center gap-1.5 px-1 pt-1 text-xs text-emerald-400">
|
||||
<CheckCircle className="h-3 w-3" />
|
||||
<span className="font-medium">Answer</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Main content (markdown for assistant, plain for user) */}
|
||||
{isUser ? (
|
||||
<p className="whitespace-pre-wrap">{msg.content}</p>
|
||||
) : (
|
||||
<div className="prose prose-invert prose-sm max-w-none prose-p:my-1 prose-pre:bg-surface-200 prose-pre:text-fg prose-code:text-brand-300">
|
||||
<Markdown>{msg.content || (msg.isStreaming ? "" : "(empty)")}</Markdown>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Streaming indicator */}
|
||||
{msg.isStreaming && (
|
||||
<span className="mt-1 inline-block h-2 w-2 animate-pulse rounded-full bg-brand-400" />
|
||||
)}
|
||||
|
||||
{/* Token metadata */}
|
||||
{msg.metadata && (
|
||||
<div className="mt-2 flex items-center gap-3 text-[10px] text-fg-subtle">
|
||||
{msg.metadata.model && <span>{msg.metadata.model}</span>}
|
||||
{msg.metadata.inTokens != null && (
|
||||
<span>in: {msg.metadata.inTokens}</span>
|
||||
)}
|
||||
{msg.metadata.outTokens != null && (
|
||||
<span>out: {msg.metadata.outTokens}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Chat page
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export default function ChatPage() {
|
||||
const messages = useConversation((s) => s.messages);
|
||||
const input = useConversation((s) => s.input);
|
||||
const chatMode = useConversation((s) => s.chatMode);
|
||||
const setInput = useConversation((s) => s.setInput);
|
||||
const setChatMode = useConversation((s) => s.setChatMode);
|
||||
const clearMessages = useConversation((s) => s.clearMessages);
|
||||
const { submitMessage } = useChat();
|
||||
const collection = useSettings((s) => s.settings.collection);
|
||||
const isLoading = useProgressStore((s) => s.isLoading);
|
||||
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Auto-scroll to bottom when messages change
|
||||
useEffect(() => {
|
||||
scrollRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||
}, [messages]);
|
||||
|
||||
const handleSubmit = useCallback(() => {
|
||||
if (input.trim()) {
|
||||
submitMessage({ input });
|
||||
}
|
||||
}, [input, submitMessage]);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSubmit();
|
||||
}
|
||||
},
|
||||
[handleSubmit],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
{/* Header */}
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<MessageSquareText className="h-6 w-6 text-brand-400" />
|
||||
<h1 className="text-2xl font-bold text-fg">Chat</h1>
|
||||
<span className="ml-2 rounded bg-surface-200 px-2 py-0.5 text-xs text-fg-subtle">
|
||||
{collection}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Mode selector */}
|
||||
<div className="flex rounded-lg border border-border bg-surface-100 p-0.5">
|
||||
{MODES.map((mode) => (
|
||||
<button
|
||||
key={mode.value}
|
||||
onClick={() => setChatMode(mode.value)}
|
||||
className={cn(
|
||||
"rounded-md px-3 py-1 text-xs font-medium transition-colors",
|
||||
chatMode === mode.value
|
||||
? "bg-brand-600 text-white"
|
||||
: "text-fg-muted hover:text-fg",
|
||||
)}
|
||||
>
|
||||
{mode.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={clearMessages}
|
||||
className="rounded-lg p-2 text-fg-subtle hover:bg-surface-200 hover:text-fg"
|
||||
title="Clear messages"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Messages */}
|
||||
<div className="flex-1 space-y-4 overflow-y-auto pb-4">
|
||||
{messages.length === 0 && (
|
||||
<div className="flex flex-col items-center justify-center py-20 text-fg-subtle">
|
||||
<MessageSquareText className="mb-3 h-10 w-10 opacity-30" />
|
||||
<p>Send a message to start a conversation.</p>
|
||||
<p className="mt-1 text-xs">
|
||||
Mode: <span className="text-fg-muted">{chatMode}</span>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{messages.map((msg) => (
|
||||
<MessageBubble key={msg.id} msg={msg} />
|
||||
))}
|
||||
<div ref={scrollRef} />
|
||||
</div>
|
||||
|
||||
{/* Loading indicator */}
|
||||
{isLoading && (
|
||||
<div className="flex items-center gap-2 pb-2 text-xs text-fg-subtle">
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
<span>Processing...</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Input area */}
|
||||
<div className="flex items-end gap-2 border-t border-border pt-4">
|
||||
<AutoTextarea
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Type your message... (Enter to send, Shift+Enter for new line)"
|
||||
maxRows={6}
|
||||
/>
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={!input.trim() || isLoading}
|
||||
className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-brand-600 text-white transition-colors hover:bg-brand-500 disabled:opacity-40"
|
||||
>
|
||||
<Send className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
490
ts/packages/workbench/src/pages/flows.tsx
Normal file
490
ts/packages/workbench/src/pages/flows.tsx
Normal file
|
|
@ -0,0 +1,490 @@
|
|||
import { useCallback, useEffect, useState } from "react";
|
||||
import {
|
||||
Workflow,
|
||||
Plus,
|
||||
Square,
|
||||
RefreshCw,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Loader2,
|
||||
AlertTriangle,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useFlows, type FlowSummary } from "@/hooks/use-flows";
|
||||
import { useSocket } from "@/providers/socket-provider";
|
||||
import { useNotification } from "@/providers/notification-provider";
|
||||
import { Dialog } from "@/components/ui/dialog";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Start flow dialog
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function StartFlowDialog({
|
||||
open,
|
||||
onClose,
|
||||
onStart,
|
||||
}: {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onStart: (
|
||||
id: string,
|
||||
blueprint: string,
|
||||
description: string,
|
||||
params: Record<string, unknown>,
|
||||
) => Promise<void>;
|
||||
}) {
|
||||
const socket = useSocket();
|
||||
const [blueprints, setBlueprints] = useState<string[]>([]);
|
||||
const [loadingBlueprints, setLoadingBlueprints] = useState(false);
|
||||
const [id, setId] = useState("");
|
||||
const [blueprint, setBlueprint] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [paramsJson, setParamsJson] = useState("{}");
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [paramsError, setParamsError] = useState<string | null>(null);
|
||||
|
||||
// Fetch blueprints when dialog opens
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setLoadingBlueprints(true);
|
||||
socket
|
||||
.flows()
|
||||
.getFlowBlueprints()
|
||||
.then((names) => {
|
||||
const list = names ?? [];
|
||||
setBlueprints(list);
|
||||
if (list.length > 0 && !blueprint) {
|
||||
setBlueprint(list[0]!);
|
||||
}
|
||||
})
|
||||
.catch(() => setBlueprints([]))
|
||||
.finally(() => setLoadingBlueprints(false));
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [open, socket]);
|
||||
|
||||
const reset = () => {
|
||||
setId("");
|
||||
setBlueprint("");
|
||||
setDescription("");
|
||||
setParamsJson("{}");
|
||||
setParamsError(null);
|
||||
setSubmitting(false);
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
let params: Record<string, unknown> = {};
|
||||
try {
|
||||
params = JSON.parse(paramsJson);
|
||||
setParamsError(null);
|
||||
} catch {
|
||||
setParamsError("Invalid JSON");
|
||||
return;
|
||||
}
|
||||
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await onStart(id, blueprint, description, params);
|
||||
reset();
|
||||
onClose();
|
||||
} catch {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const isValid = id.trim().length > 0 && blueprint.length > 0 && description.trim().length > 0;
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onClose={() => {
|
||||
if (!submitting) {
|
||||
reset();
|
||||
onClose();
|
||||
}
|
||||
}}
|
||||
title="Start Flow"
|
||||
footer={
|
||||
<>
|
||||
<button
|
||||
onClick={() => {
|
||||
reset();
|
||||
onClose();
|
||||
}}
|
||||
disabled={submitting}
|
||||
className="rounded-lg border border-border px-4 py-2 text-sm text-fg-muted hover:bg-surface-200 disabled:opacity-40"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={!isValid || submitting}
|
||||
className="flex items-center gap-2 rounded-lg bg-brand-600 px-4 py-2 text-sm font-medium text-white hover:bg-brand-500 disabled:opacity-40"
|
||||
>
|
||||
{submitting && <Loader2 className="h-3.5 w-3.5 animate-spin" />}
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
Start
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{/* Flow ID */}
|
||||
<div className="mb-3 space-y-1.5">
|
||||
<label className="block text-sm font-medium text-fg-muted">
|
||||
Flow ID <span className="text-error">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={id}
|
||||
onChange={(e) => setId(e.target.value)}
|
||||
placeholder="my-flow-id"
|
||||
className="w-full rounded-lg border border-border bg-surface-100 px-3 py-2 text-sm text-fg placeholder:text-fg-subtle focus:border-brand-500 focus:outline-none focus:ring-1 focus:ring-brand-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Blueprint name */}
|
||||
<div className="mb-3 space-y-1.5">
|
||||
<label className="block text-sm font-medium text-fg-muted">
|
||||
Blueprint <span className="text-error">*</span>
|
||||
</label>
|
||||
{loadingBlueprints ? (
|
||||
<div className="flex items-center gap-2 py-2 text-xs text-fg-subtle">
|
||||
<Loader2 className="h-3 w-3 animate-spin" /> Loading blueprints...
|
||||
</div>
|
||||
) : (
|
||||
<select
|
||||
value={blueprint}
|
||||
onChange={(e) => setBlueprint(e.target.value)}
|
||||
className="w-full rounded-lg border border-border bg-surface-100 px-3 py-2 text-sm text-fg focus:border-brand-500 focus:outline-none focus:ring-1 focus:ring-brand-500"
|
||||
>
|
||||
<option value="" disabled>
|
||||
Select a blueprint
|
||||
</option>
|
||||
{blueprints.map((bp) => (
|
||||
<option key={bp} value={bp}>
|
||||
{bp}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<div className="mb-3 space-y-1.5">
|
||||
<label className="block text-sm font-medium text-fg-muted">
|
||||
Description <span className="text-error">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="Human-readable description"
|
||||
className="w-full rounded-lg border border-border bg-surface-100 px-3 py-2 text-sm text-fg placeholder:text-fg-subtle focus:border-brand-500 focus:outline-none focus:ring-1 focus:ring-brand-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Parameters (JSON) */}
|
||||
<div className="space-y-1.5">
|
||||
<label className="block text-sm font-medium text-fg-muted">
|
||||
Parameters (JSON)
|
||||
</label>
|
||||
<textarea
|
||||
value={paramsJson}
|
||||
onChange={(e) => {
|
||||
setParamsJson(e.target.value);
|
||||
setParamsError(null);
|
||||
}}
|
||||
rows={4}
|
||||
className={cn(
|
||||
"w-full resize-none rounded-lg border bg-surface-100 px-3 py-2 font-mono text-xs text-fg placeholder:text-fg-subtle focus:outline-none focus:ring-1",
|
||||
paramsError
|
||||
? "border-error focus:border-error focus:ring-error"
|
||||
: "border-border focus:border-brand-500 focus:ring-brand-500",
|
||||
)}
|
||||
/>
|
||||
{paramsError && (
|
||||
<p className="text-xs text-error">{paramsError}</p>
|
||||
)}
|
||||
</div>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Stop flow confirm dialog
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function StopFlowDialog({
|
||||
open,
|
||||
flowId,
|
||||
onClose,
|
||||
onConfirm,
|
||||
}: {
|
||||
open: boolean;
|
||||
flowId: string;
|
||||
onClose: () => void;
|
||||
onConfirm: () => void;
|
||||
}) {
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
title="Stop Flow"
|
||||
footer={
|
||||
<>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="rounded-lg border border-border px-4 py-2 text-sm text-fg-muted hover:bg-surface-200"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={onConfirm}
|
||||
className="rounded-lg bg-error px-4 py-2 text-sm font-medium text-white hover:opacity-90"
|
||||
>
|
||||
Stop
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<AlertTriangle className="mt-0.5 h-5 w-5 shrink-0 text-error" />
|
||||
<p className="text-sm text-fg-muted">
|
||||
Are you sure you want to stop flow{" "}
|
||||
<span className="font-mono font-medium text-fg">{flowId}</span>?
|
||||
</p>
|
||||
</div>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Flow detail row (expandable)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function FlowRow({
|
||||
flow,
|
||||
onStop,
|
||||
}: {
|
||||
flow: FlowSummary;
|
||||
onStop: (id: string) => void;
|
||||
}) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
// Determine all the extra keys beyond id/description
|
||||
const detailKeys = Object.keys(flow).filter(
|
||||
(k) => k !== "id" && k !== "description",
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<tr
|
||||
className="cursor-pointer hover:bg-surface-100/50"
|
||||
onClick={() => setExpanded((p) => !p)}
|
||||
>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
{expanded ? (
|
||||
<ChevronDown className="h-3.5 w-3.5 shrink-0 text-fg-subtle" />
|
||||
) : (
|
||||
<ChevronRight className="h-3.5 w-3.5 shrink-0 text-fg-subtle" />
|
||||
)}
|
||||
<span className="font-mono text-sm text-fg">{flow.id}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-fg-muted">
|
||||
{flow.description || "--"}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<Badge variant="success">Running</Badge>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onStop(flow.id);
|
||||
}}
|
||||
className="rounded p-1.5 text-fg-subtle hover:bg-error/10 hover:text-error"
|
||||
title="Stop flow"
|
||||
>
|
||||
<Square className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
{/* Detail row */}
|
||||
{expanded && detailKeys.length > 0 && (
|
||||
<tr>
|
||||
<td colSpan={4} className="bg-surface-50 px-8 py-3">
|
||||
<div className="grid grid-cols-2 gap-x-6 gap-y-2 text-xs">
|
||||
{detailKeys.map((key) => (
|
||||
<div key={key}>
|
||||
<span className="font-medium text-fg-muted">{key}: </span>
|
||||
<span className="text-fg-subtle">
|
||||
{typeof flow[key] === "object"
|
||||
? JSON.stringify(flow[key])
|
||||
: String(flow[key] ?? "")}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Flows page
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export default function FlowsPage() {
|
||||
const { flows, loading, error, getFlows, startFlow, stopFlow } = useFlows();
|
||||
const notify = useNotification();
|
||||
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [stopTarget, setStopTarget] = useState<string | null>(null);
|
||||
|
||||
// Auto-refresh every 10 seconds
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
getFlows();
|
||||
}, 10_000);
|
||||
return () => clearInterval(interval);
|
||||
}, [getFlows]);
|
||||
|
||||
// Also refresh on window focus
|
||||
useEffect(() => {
|
||||
const handler = () => getFlows();
|
||||
window.addEventListener("focus", handler);
|
||||
return () => window.removeEventListener("focus", handler);
|
||||
}, [getFlows]);
|
||||
|
||||
const handleStart = async (
|
||||
id: string,
|
||||
blueprint: string,
|
||||
description: string,
|
||||
params: Record<string, unknown>,
|
||||
) => {
|
||||
try {
|
||||
await startFlow(id, blueprint, description, params);
|
||||
notify.success("Flow started", `Flow "${id}" has been started.`);
|
||||
} catch (err) {
|
||||
notify.error(
|
||||
"Failed to start flow",
|
||||
err instanceof Error ? err.message : String(err),
|
||||
);
|
||||
throw err; // re-throw so dialog stays open
|
||||
}
|
||||
};
|
||||
|
||||
const handleStop = async () => {
|
||||
if (!stopTarget) return;
|
||||
try {
|
||||
await stopFlow(stopTarget);
|
||||
notify.success("Flow stopped", `Flow "${stopTarget}" has been stopped.`);
|
||||
} catch (err) {
|
||||
notify.error(
|
||||
"Failed to stop flow",
|
||||
err instanceof Error ? err.message : String(err),
|
||||
);
|
||||
}
|
||||
setStopTarget(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
{/* Header */}
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Workflow className="h-6 w-6 text-brand-400" />
|
||||
<h1 className="text-2xl font-bold text-fg">Flows</h1>
|
||||
<span className="ml-2 rounded bg-surface-200 px-2 py-0.5 text-xs text-fg-subtle">
|
||||
{flows.length} active
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => getFlows()}
|
||||
disabled={loading}
|
||||
className="flex items-center gap-1.5 rounded-lg border border-border px-3 py-2 text-sm text-fg-muted transition-colors hover:bg-surface-200 disabled:opacity-40"
|
||||
>
|
||||
<RefreshCw className={cn("h-3.5 w-3.5", loading && "animate-spin")} />
|
||||
Refresh
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setCreateOpen(true)}
|
||||
className="flex items-center gap-2 rounded-lg bg-brand-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-brand-500"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
Start Flow
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
{loading && flows.length === 0 && (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="mr-2 h-5 w-5 animate-spin text-fg-subtle" />
|
||||
<span className="text-fg-subtle">Loading flows...</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<p className="mb-4 rounded-lg bg-error/10 px-4 py-2 text-sm text-error">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{!loading && !error && flows.length === 0 && (
|
||||
<div className="flex flex-1 flex-col items-center justify-center">
|
||||
<Workflow className="mb-3 h-10 w-10 text-fg-subtle opacity-30" />
|
||||
<p className="text-fg-subtle">No flows configured.</p>
|
||||
<p className="mt-1 text-xs text-fg-subtle">
|
||||
Click "Start Flow" to create one.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{flows.length > 0 && (
|
||||
<div className="overflow-x-auto rounded-lg border border-border">
|
||||
<table className="w-full text-left text-sm">
|
||||
<thead className="border-b border-border bg-surface-100 text-fg-muted">
|
||||
<tr>
|
||||
<th className="px-4 py-3 font-medium">ID</th>
|
||||
<th className="px-4 py-3 font-medium">Description</th>
|
||||
<th className="px-4 py-3 font-medium">Status</th>
|
||||
<th className="px-4 py-3 font-medium text-right">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{flows.map((flow) => (
|
||||
<FlowRow
|
||||
key={flow.id}
|
||||
flow={flow}
|
||||
onStop={(id) => setStopTarget(id)}
|
||||
/>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Dialogs */}
|
||||
<StartFlowDialog
|
||||
open={createOpen}
|
||||
onClose={() => setCreateOpen(false)}
|
||||
onStart={handleStart}
|
||||
/>
|
||||
|
||||
<StopFlowDialog
|
||||
open={stopTarget != null}
|
||||
flowId={stopTarget ?? ""}
|
||||
onClose={() => setStopTarget(null)}
|
||||
onConfirm={handleStop}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
586
ts/packages/workbench/src/pages/graph.tsx
Normal file
586
ts/packages/workbench/src/pages/graph.tsx
Normal file
|
|
@ -0,0 +1,586 @@
|
|||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import {
|
||||
Rotate3d,
|
||||
Search,
|
||||
ZoomIn,
|
||||
ZoomOut,
|
||||
Maximize,
|
||||
Loader2,
|
||||
X,
|
||||
ArrowRight,
|
||||
ArrowLeft,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useSocket } from "@/providers/socket-provider";
|
||||
import { useSessionStore } from "@/hooks/use-session-store";
|
||||
import { useSettings } from "@/providers/settings-provider";
|
||||
import { useProgressStore } from "@/hooks/use-progress-store";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import type { Triple, Term } from "@trustgraph/client";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Lazy-load ForceGraph2D to keep bundle size down
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// react-force-graph-2d ships a default export
|
||||
import ForceGraph2D, {
|
||||
type ForceGraphMethods,
|
||||
type NodeObject,
|
||||
type LinkObject,
|
||||
} from "react-force-graph-2d";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface GraphNode extends NodeObject {
|
||||
id: string;
|
||||
label: string;
|
||||
color?: string;
|
||||
/** Number of connections (used for sizing) */
|
||||
degree: number;
|
||||
}
|
||||
|
||||
interface GraphLink extends LinkObject {
|
||||
source: string;
|
||||
target: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface GraphData {
|
||||
nodes: GraphNode[];
|
||||
links: GraphLink[];
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers -- Term value extraction
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const RDFS_LABEL = "http://www.w3.org/2000/01/rdf-schema#label";
|
||||
const RDF_TYPE = "http://www.w3.org/1999/02/22-rdf-syntax-ns#type";
|
||||
|
||||
function termValue(t: Term): string {
|
||||
switch (t.t) {
|
||||
case "i":
|
||||
return t.i;
|
||||
case "l":
|
||||
return t.v;
|
||||
case "b":
|
||||
return t.d;
|
||||
case "t":
|
||||
return "[triple]";
|
||||
}
|
||||
}
|
||||
|
||||
function isIri(t: Term): boolean {
|
||||
return t.t === "i";
|
||||
}
|
||||
|
||||
/** Extract the local name from a URI for display */
|
||||
function localName(uri: string): string {
|
||||
const hash = uri.lastIndexOf("#");
|
||||
const slash = uri.lastIndexOf("/");
|
||||
const idx = Math.max(hash, slash);
|
||||
if (idx >= 0 && idx < uri.length - 1) return uri.substring(idx + 1);
|
||||
return uri;
|
||||
}
|
||||
|
||||
/** Deterministic color from a string (for node types) */
|
||||
function hashColor(s: string): string {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < s.length; i++) {
|
||||
hash = s.charCodeAt(i) + ((hash << 5) - hash);
|
||||
}
|
||||
const hue = ((hash % 360) + 360) % 360;
|
||||
return `hsl(${hue}, 60%, 55%)`;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Build graph data from triples
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function triplesToGraph(triples: Triple[]): {
|
||||
data: GraphData;
|
||||
labelMap: Map<string, string>;
|
||||
typeMap: Map<string, string>;
|
||||
} {
|
||||
const labelMap = new Map<string, string>();
|
||||
const typeMap = new Map<string, string>();
|
||||
|
||||
// First pass: collect labels and types
|
||||
for (const t of triples) {
|
||||
const pred = termValue(t.p);
|
||||
if (pred === RDFS_LABEL && t.o.t === "l") {
|
||||
labelMap.set(termValue(t.s), t.o.v);
|
||||
}
|
||||
if (pred === RDF_TYPE && isIri(t.o)) {
|
||||
typeMap.set(termValue(t.s), termValue(t.o));
|
||||
}
|
||||
}
|
||||
|
||||
// Second pass: build nodes and links (skip structural triples)
|
||||
const nodeMap = new Map<string, GraphNode>();
|
||||
const links: GraphLink[] = [];
|
||||
|
||||
const ensureNode = (uri: string): void => {
|
||||
if (!nodeMap.has(uri)) {
|
||||
const type = typeMap.get(uri);
|
||||
nodeMap.set(uri, {
|
||||
id: uri,
|
||||
label: labelMap.get(uri) ?? localName(uri),
|
||||
color: type ? hashColor(localName(type)) : "#5b80ff",
|
||||
degree: 0,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
for (const t of triples) {
|
||||
const sVal = termValue(t.s);
|
||||
const pVal = termValue(t.p);
|
||||
const oVal = termValue(t.o);
|
||||
|
||||
// Skip label and type predicates -- they are metadata, not graph edges
|
||||
if (pVal === RDFS_LABEL) continue;
|
||||
if (pVal === RDF_TYPE) continue;
|
||||
|
||||
// Only build edges when both endpoints are IRIs (entity-to-entity)
|
||||
if (!isIri(t.s) || !isIri(t.o)) continue;
|
||||
|
||||
ensureNode(sVal);
|
||||
ensureNode(oVal);
|
||||
nodeMap.get(sVal)!.degree++;
|
||||
nodeMap.get(oVal)!.degree++;
|
||||
|
||||
links.push({
|
||||
source: sVal,
|
||||
target: oVal,
|
||||
label: labelMap.get(pVal) ?? localName(pVal),
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
data: { nodes: Array.from(nodeMap.values()), links },
|
||||
labelMap,
|
||||
typeMap,
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Node detail panel
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function NodeDetailPanel({
|
||||
nodeId,
|
||||
label,
|
||||
triples,
|
||||
labelMap,
|
||||
onClose,
|
||||
}: {
|
||||
nodeId: string;
|
||||
label: string;
|
||||
triples: Triple[];
|
||||
labelMap: Map<string, string>;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
// Find triples where this node is subject or object
|
||||
const related = useMemo(() => {
|
||||
const outbound: { predicate: string; object: string; objectLabel: string }[] = [];
|
||||
const inbound: { predicate: string; subject: string; subjectLabel: string }[] = [];
|
||||
|
||||
for (const t of triples) {
|
||||
const sVal = termValue(t.s);
|
||||
const pVal = termValue(t.p);
|
||||
const oVal = termValue(t.o);
|
||||
|
||||
if (pVal === RDFS_LABEL || pVal === RDF_TYPE) continue;
|
||||
|
||||
if (sVal === nodeId) {
|
||||
outbound.push({
|
||||
predicate: labelMap.get(pVal) ?? localName(pVal),
|
||||
object: oVal,
|
||||
objectLabel: labelMap.get(oVal) ?? localName(oVal),
|
||||
});
|
||||
}
|
||||
if (oVal === nodeId) {
|
||||
inbound.push({
|
||||
predicate: labelMap.get(pVal) ?? localName(pVal),
|
||||
subject: sVal,
|
||||
subjectLabel: labelMap.get(sVal) ?? localName(sVal),
|
||||
});
|
||||
}
|
||||
}
|
||||
return { outbound, inbound };
|
||||
}, [nodeId, triples, labelMap]);
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-80 shrink-0 flex-col border-l border-border bg-surface-50">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between border-b border-border px-4 py-3">
|
||||
<h3 className="truncate text-sm font-semibold text-fg">{label}</h3>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="rounded p-1 text-fg-subtle hover:bg-surface-200 hover:text-fg"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-4">
|
||||
<p className="mb-3 truncate font-mono text-[10px] text-fg-subtle">
|
||||
{nodeId}
|
||||
</p>
|
||||
|
||||
{/* Outbound relationships */}
|
||||
{related.outbound.length > 0 && (
|
||||
<div className="mb-4">
|
||||
<h4 className="mb-2 flex items-center gap-1.5 text-xs font-medium text-fg-muted">
|
||||
<ArrowRight className="h-3 w-3" />
|
||||
Outbound ({related.outbound.length})
|
||||
</h4>
|
||||
<div className="space-y-1">
|
||||
{related.outbound.map((r, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="flex items-center gap-1.5 rounded bg-surface-100 px-2 py-1.5 text-xs"
|
||||
>
|
||||
<Badge variant="default">{r.predicate}</Badge>
|
||||
<span className="truncate text-fg-muted">{r.objectLabel}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Inbound relationships */}
|
||||
{related.inbound.length > 0 && (
|
||||
<div>
|
||||
<h4 className="mb-2 flex items-center gap-1.5 text-xs font-medium text-fg-muted">
|
||||
<ArrowLeft className="h-3 w-3" />
|
||||
Inbound ({related.inbound.length})
|
||||
</h4>
|
||||
<div className="space-y-1">
|
||||
{related.inbound.map((r, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="flex items-center gap-1.5 rounded bg-surface-100 px-2 py-1.5 text-xs"
|
||||
>
|
||||
<span className="truncate text-fg-muted">{r.subjectLabel}</span>
|
||||
<Badge variant="default">{r.predicate}</Badge>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{related.outbound.length === 0 && related.inbound.length === 0 && (
|
||||
<p className="text-xs text-fg-subtle">No relationships found.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Graph page
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export default function GraphPage() {
|
||||
const socket = useSocket();
|
||||
const flowId = useSessionStore((s) => s.flowId);
|
||||
const collection = useSettings((s) => s.settings.collection);
|
||||
const addActivity = useProgressStore((s) => s.addActivity);
|
||||
const removeActivity = useProgressStore((s) => s.removeActivity);
|
||||
|
||||
const [triples, setTriples] = useState<Triple[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [selectedNode, setSelectedNode] = useState<string | null>(null);
|
||||
|
||||
const fgRef = useRef<ForceGraphMethods<GraphNode, GraphLink> | undefined>(
|
||||
undefined,
|
||||
);
|
||||
|
||||
// Fetch triples
|
||||
const fetchTriples = useCallback(async () => {
|
||||
const act = "Load graph";
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
addActivity(act);
|
||||
|
||||
const flow = socket.flow(flowId);
|
||||
const result = await flow.triplesQuery(
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
2000,
|
||||
collection,
|
||||
);
|
||||
setTriples(result);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
removeActivity(act);
|
||||
}
|
||||
}, [socket, flowId, collection, addActivity, removeActivity]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchTriples();
|
||||
}, [fetchTriples]);
|
||||
|
||||
// Build graph
|
||||
const { data: graphData, labelMap } = useMemo(
|
||||
() => triplesToGraph(triples),
|
||||
[triples],
|
||||
);
|
||||
|
||||
// Search filter -- highlight matching nodes
|
||||
const searchLower = searchTerm.toLowerCase();
|
||||
const matchingIds = useMemo(() => {
|
||||
if (!searchLower) return new Set<string>();
|
||||
return new Set(
|
||||
graphData.nodes
|
||||
.filter(
|
||||
(n) =>
|
||||
n.label.toLowerCase().includes(searchLower) ||
|
||||
n.id.toLowerCase().includes(searchLower),
|
||||
)
|
||||
.map((n) => n.id),
|
||||
);
|
||||
}, [graphData.nodes, searchLower]);
|
||||
|
||||
const selectedLabel = selectedNode
|
||||
? labelMap.get(selectedNode) ?? localName(selectedNode)
|
||||
: "";
|
||||
|
||||
// Zoom helpers
|
||||
const zoomIn = () => fgRef.current?.zoom(2, 300);
|
||||
const zoomOut = () => fgRef.current?.zoom(0.5, 300);
|
||||
const zoomFit = () =>
|
||||
fgRef.current?.zoomToFit(400, 40);
|
||||
|
||||
// Node paint callback
|
||||
const paintNode = useCallback(
|
||||
(node: GraphNode, ctx: CanvasRenderingContext2D, globalScale: number) => {
|
||||
const isSelected = node.id === selectedNode;
|
||||
const isMatch = matchingIds.size > 0 && matchingIds.has(node.id);
|
||||
const dim = matchingIds.size > 0 && !isMatch && !isSelected;
|
||||
|
||||
const radius = Math.max(3, Math.sqrt(node.degree + 1) * 2.5);
|
||||
const x = node.x ?? 0;
|
||||
const y = node.y ?? 0;
|
||||
|
||||
// Node circle
|
||||
ctx.beginPath();
|
||||
ctx.arc(x, y, radius, 0, 2 * Math.PI);
|
||||
ctx.fillStyle = dim
|
||||
? "rgba(100,100,100,0.3)"
|
||||
: isSelected
|
||||
? "#fbbf24"
|
||||
: isMatch
|
||||
? "#22c55e"
|
||||
: node.color ?? "#5b80ff";
|
||||
ctx.fill();
|
||||
|
||||
if (isSelected || isMatch) {
|
||||
ctx.strokeStyle = isSelected ? "#fbbf24" : "#22c55e";
|
||||
ctx.lineWidth = 1.5 / globalScale;
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
// Label
|
||||
const fontSize = Math.max(10 / globalScale, 2);
|
||||
ctx.font = `${fontSize}px Inter, sans-serif`;
|
||||
ctx.textAlign = "center";
|
||||
ctx.textBaseline = "top";
|
||||
ctx.fillStyle = dim ? "rgba(100,100,100,0.3)" : "rgba(250,250,250,0.9)";
|
||||
ctx.fillText(node.label, x, y + radius + 1);
|
||||
},
|
||||
[selectedNode, matchingIds],
|
||||
);
|
||||
|
||||
// Link label painting
|
||||
const paintLink = useCallback(
|
||||
(link: GraphLink, ctx: CanvasRenderingContext2D, globalScale: number) => {
|
||||
if (globalScale < 1.5) return; // only show labels when zoomed in enough
|
||||
|
||||
const src = link.source as unknown as GraphNode;
|
||||
const tgt = link.target as unknown as GraphNode;
|
||||
if (!src.x || !tgt.x) return;
|
||||
|
||||
const midX = ((src.x ?? 0) + (tgt.x ?? 0)) / 2;
|
||||
const midY = ((src.y ?? 0) + (tgt.y ?? 0)) / 2;
|
||||
|
||||
const fontSize = Math.max(8 / globalScale, 1.5);
|
||||
ctx.font = `${fontSize}px Inter, sans-serif`;
|
||||
ctx.textAlign = "center";
|
||||
ctx.textBaseline = "middle";
|
||||
ctx.fillStyle = "rgba(161,161,170,0.7)";
|
||||
ctx.fillText(link.label, midX, midY);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
{/* Header */}
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Rotate3d className="h-6 w-6 text-brand-400" />
|
||||
<h1 className="text-2xl font-bold text-fg">Graph</h1>
|
||||
<span className="ml-2 rounded bg-surface-200 px-2 py-0.5 text-xs text-fg-subtle">
|
||||
{graphData.nodes.length} nodes, {graphData.links.length} edges
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Search */}
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-fg-subtle" />
|
||||
<input
|
||||
type="text"
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
placeholder="Search nodes..."
|
||||
className="w-48 rounded-lg border border-border bg-surface-100 py-1.5 pl-8 pr-3 text-xs text-fg placeholder:text-fg-subtle focus:border-brand-500 focus:outline-none focus:ring-1 focus:ring-brand-500"
|
||||
/>
|
||||
{searchTerm && (
|
||||
<button
|
||||
onClick={() => setSearchTerm("")}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 text-fg-subtle hover:text-fg"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Zoom controls */}
|
||||
<div className="flex rounded-lg border border-border bg-surface-100">
|
||||
<button
|
||||
onClick={zoomIn}
|
||||
className="px-2 py-1.5 text-fg-muted hover:text-fg"
|
||||
title="Zoom in"
|
||||
>
|
||||
<ZoomIn className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={zoomOut}
|
||||
className="border-l border-r border-border px-2 py-1.5 text-fg-muted hover:text-fg"
|
||||
title="Zoom out"
|
||||
>
|
||||
<ZoomOut className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={zoomFit}
|
||||
className="px-2 py-1.5 text-fg-muted hover:text-fg"
|
||||
title="Fit to view"
|
||||
>
|
||||
<Maximize className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={fetchTriples}
|
||||
disabled={loading}
|
||||
className="flex items-center gap-1.5 rounded-lg border border-border px-3 py-1.5 text-xs text-fg-muted hover:bg-surface-200 disabled:opacity-40"
|
||||
>
|
||||
{loading ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<Rotate3d className="h-3.5 w-3.5" />
|
||||
)}
|
||||
Reload
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
{error && (
|
||||
<p className="mb-4 rounded-lg bg-error/10 px-4 py-2 text-sm text-error">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{loading && triples.length === 0 && (
|
||||
<div className="flex flex-1 items-center justify-center">
|
||||
<Loader2 className="mr-2 h-5 w-5 animate-spin text-fg-subtle" />
|
||||
<span className="text-fg-subtle">Loading graph data...</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && graphData.nodes.length === 0 && (
|
||||
<div className="flex flex-1 items-center justify-center rounded-lg border border-dashed border-border">
|
||||
<div className="text-center">
|
||||
<Rotate3d className="mx-auto mb-3 h-10 w-10 text-fg-subtle opacity-30" />
|
||||
<p className="text-fg-subtle">No graph data in this collection.</p>
|
||||
<p className="mt-1 text-xs text-fg-subtle">
|
||||
Upload documents and process them to populate the knowledge graph.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{graphData.nodes.length > 0 && (
|
||||
<div className="flex flex-1 overflow-hidden rounded-lg border border-border">
|
||||
{/* Graph canvas */}
|
||||
<div className="relative flex-1 bg-surface-0">
|
||||
<ForceGraph2D
|
||||
ref={fgRef}
|
||||
graphData={graphData}
|
||||
nodeCanvasObject={paintNode}
|
||||
nodePointerAreaPaint={(node: GraphNode, color, ctx) => {
|
||||
const radius = Math.max(3, Math.sqrt(node.degree + 1) * 2.5);
|
||||
ctx.beginPath();
|
||||
ctx.arc(node.x ?? 0, node.y ?? 0, radius + 2, 0, 2 * Math.PI);
|
||||
ctx.fillStyle = color;
|
||||
ctx.fill();
|
||||
}}
|
||||
linkCanvasObjectMode={() => "after"}
|
||||
linkCanvasObject={paintLink}
|
||||
linkColor={() => "rgba(91,128,255,0.25)"}
|
||||
linkDirectionalArrowLength={4}
|
||||
linkDirectionalArrowRelPos={0.85}
|
||||
onNodeClick={(node: GraphNode) => {
|
||||
setSelectedNode((prev) =>
|
||||
prev === node.id ? null : node.id,
|
||||
);
|
||||
}}
|
||||
onBackgroundClick={() => setSelectedNode(null)}
|
||||
backgroundColor="transparent"
|
||||
width={undefined}
|
||||
height={undefined}
|
||||
/>
|
||||
|
||||
{/* Search results badge overlay */}
|
||||
{searchTerm && matchingIds.size > 0 && (
|
||||
<div className="absolute bottom-3 left-3">
|
||||
<Badge variant="success">
|
||||
{matchingIds.size} match{matchingIds.size > 1 ? "es" : ""}
|
||||
</Badge>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Detail panel */}
|
||||
{selectedNode && (
|
||||
<NodeDetailPanel
|
||||
nodeId={selectedNode}
|
||||
label={selectedLabel}
|
||||
triples={triples}
|
||||
labelMap={labelMap}
|
||||
onClose={() => setSelectedNode(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
486
ts/packages/workbench/src/pages/library.tsx
Normal file
486
ts/packages/workbench/src/pages/library.tsx
Normal file
|
|
@ -0,0 +1,486 @@
|
|||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
LibraryBig,
|
||||
Upload,
|
||||
Trash2,
|
||||
RefreshCw,
|
||||
FileText,
|
||||
FileType2,
|
||||
Loader2,
|
||||
X,
|
||||
AlertTriangle,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useLibrary } from "@/hooks/use-library";
|
||||
import { useSettings } from "@/providers/settings-provider";
|
||||
import { useNotification } from "@/providers/notification-provider";
|
||||
import { Dialog } from "@/components/ui/dialog";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import type { DocumentMetadata } from "@trustgraph/client";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Upload dialog
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function UploadDialog({
|
||||
open,
|
||||
onClose,
|
||||
onUpload,
|
||||
}: {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onUpload: (
|
||||
data: string,
|
||||
mimeType: string,
|
||||
title: string,
|
||||
comments: string,
|
||||
tags: string[],
|
||||
) => Promise<void>;
|
||||
}) {
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [title, setTitle] = useState("");
|
||||
const [tags, setTags] = useState("");
|
||||
const [comments, setComments] = useState("");
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [dragOver, setDragOver] = useState(false);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const reset = () => {
|
||||
setFile(null);
|
||||
setTitle("");
|
||||
setTags("");
|
||||
setComments("");
|
||||
setUploading(false);
|
||||
};
|
||||
|
||||
const handleFile = (f: File) => {
|
||||
setFile(f);
|
||||
if (!title) setTitle(f.name.replace(/\.[^/.]+$/, ""));
|
||||
};
|
||||
|
||||
const handleDrop = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
setDragOver(false);
|
||||
const f = e.dataTransfer.files[0];
|
||||
if (f) handleFile(f);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!file) return;
|
||||
setUploading(true);
|
||||
try {
|
||||
const base64 = await fileToBase64(file);
|
||||
const tagList = tags
|
||||
.split(",")
|
||||
.map((t) => t.trim())
|
||||
.filter(Boolean);
|
||||
await onUpload(base64, file.type || "application/octet-stream", title, comments, tagList);
|
||||
reset();
|
||||
onClose();
|
||||
} catch {
|
||||
setUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onClose={() => {
|
||||
if (!uploading) {
|
||||
reset();
|
||||
onClose();
|
||||
}
|
||||
}}
|
||||
title="Upload Document"
|
||||
footer={
|
||||
<>
|
||||
<button
|
||||
onClick={() => {
|
||||
reset();
|
||||
onClose();
|
||||
}}
|
||||
disabled={uploading}
|
||||
className="rounded-lg border border-border px-4 py-2 text-sm text-fg-muted hover:bg-surface-200 disabled:opacity-40"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={!file || !title.trim() || uploading}
|
||||
className="flex items-center gap-2 rounded-lg bg-brand-600 px-4 py-2 text-sm font-medium text-white hover:bg-brand-500 disabled:opacity-40"
|
||||
>
|
||||
{uploading && <Loader2 className="h-3.5 w-3.5 animate-spin" />}
|
||||
Upload
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{/* Drop zone */}
|
||||
<div
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault();
|
||||
setDragOver(true);
|
||||
}}
|
||||
onDragLeave={() => setDragOver(false)}
|
||||
onDrop={handleDrop}
|
||||
onClick={() => inputRef.current?.click()}
|
||||
className={cn(
|
||||
"mb-4 flex cursor-pointer flex-col items-center justify-center rounded-lg border-2 border-dashed px-6 py-8 transition-colors",
|
||||
dragOver
|
||||
? "border-brand-500 bg-brand-500/10"
|
||||
: "border-border hover:border-border-hover",
|
||||
)}
|
||||
>
|
||||
<Upload className="mb-2 h-8 w-8 text-fg-subtle" />
|
||||
{file ? (
|
||||
<div className="flex items-center gap-2 text-sm text-fg">
|
||||
<FileText className="h-4 w-4" />
|
||||
<span>{file.name}</span>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setFile(null);
|
||||
}}
|
||||
className="ml-1 text-fg-subtle hover:text-fg"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<p className="text-sm text-fg-muted">
|
||||
Drop a file here or click to browse
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-fg-subtle">PDF, TXT, or other text formats</p>
|
||||
</>
|
||||
)}
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
className="hidden"
|
||||
accept=".pdf,.txt,.md,.csv,.json,.xml,.html"
|
||||
onChange={(e) => {
|
||||
const f = e.target.files?.[0];
|
||||
if (f) handleFile(f);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Title */}
|
||||
<div className="mb-3 space-y-1.5">
|
||||
<label className="block text-sm font-medium text-fg-muted">Title</label>
|
||||
<input
|
||||
type="text"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder="Document title"
|
||||
className="w-full rounded-lg border border-border bg-surface-100 px-3 py-2 text-sm text-fg placeholder:text-fg-subtle focus:border-brand-500 focus:outline-none focus:ring-1 focus:ring-brand-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Comments */}
|
||||
<div className="mb-3 space-y-1.5">
|
||||
<label className="block text-sm font-medium text-fg-muted">Comments</label>
|
||||
<input
|
||||
type="text"
|
||||
value={comments}
|
||||
onChange={(e) => setComments(e.target.value)}
|
||||
placeholder="Optional comments"
|
||||
className="w-full rounded-lg border border-border bg-surface-100 px-3 py-2 text-sm text-fg placeholder:text-fg-subtle focus:border-brand-500 focus:outline-none focus:ring-1 focus:ring-brand-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Tags */}
|
||||
<div className="space-y-1.5">
|
||||
<label className="block text-sm font-medium text-fg-muted">Tags</label>
|
||||
<input
|
||||
type="text"
|
||||
value={tags}
|
||||
onChange={(e) => setTags(e.target.value)}
|
||||
placeholder="Comma-separated tags"
|
||||
className="w-full rounded-lg border border-border bg-surface-100 px-3 py-2 text-sm text-fg placeholder:text-fg-subtle focus:border-brand-500 focus:outline-none focus:ring-1 focus:ring-brand-500"
|
||||
/>
|
||||
</div>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Confirm delete dialog
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function ConfirmDeleteDialog({
|
||||
open,
|
||||
docTitle,
|
||||
onClose,
|
||||
onConfirm,
|
||||
}: {
|
||||
open: boolean;
|
||||
docTitle: string;
|
||||
onClose: () => void;
|
||||
onConfirm: () => void;
|
||||
}) {
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
title="Delete Document"
|
||||
footer={
|
||||
<>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="rounded-lg border border-border px-4 py-2 text-sm text-fg-muted hover:bg-surface-200"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={onConfirm}
|
||||
className="rounded-lg bg-error px-4 py-2 text-sm font-medium text-white hover:opacity-90"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<AlertTriangle className="mt-0.5 h-5 w-5 shrink-0 text-error" />
|
||||
<p className="text-sm text-fg-muted">
|
||||
Are you sure you want to delete{" "}
|
||||
<span className="font-medium text-fg">{docTitle || "this document"}</span>?
|
||||
This action cannot be undone.
|
||||
</p>
|
||||
</div>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Library page
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export default function LibraryPage() {
|
||||
const {
|
||||
documents,
|
||||
processing,
|
||||
loading,
|
||||
error,
|
||||
getDocuments,
|
||||
uploadDocument,
|
||||
removeDocument,
|
||||
getProcessing,
|
||||
} = useLibrary();
|
||||
const collection = useSettings((s) => s.settings.collection);
|
||||
const notify = useNotification();
|
||||
|
||||
const [uploadOpen, setUploadOpen] = useState(false);
|
||||
const [deleteTarget, setDeleteTarget] = useState<DocumentMetadata | null>(null);
|
||||
|
||||
// Load documents and processing on mount
|
||||
useEffect(() => {
|
||||
getDocuments();
|
||||
getProcessing();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const handleUpload = async (
|
||||
data: string,
|
||||
mimeType: string,
|
||||
title: string,
|
||||
comments: string,
|
||||
tags: string[],
|
||||
) => {
|
||||
try {
|
||||
await uploadDocument(data, mimeType, title, comments, tags);
|
||||
notify.success("Document uploaded", `"${title}" is being processed.`);
|
||||
getProcessing();
|
||||
} catch {
|
||||
notify.error("Upload failed", "Could not upload the document.");
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!deleteTarget?.id) return;
|
||||
try {
|
||||
await removeDocument(deleteTarget.id, collection);
|
||||
notify.success("Document deleted");
|
||||
} catch {
|
||||
notify.error("Delete failed");
|
||||
}
|
||||
setDeleteTarget(null);
|
||||
};
|
||||
|
||||
const handleRefresh = () => {
|
||||
getDocuments();
|
||||
getProcessing();
|
||||
};
|
||||
|
||||
const guessKind = (doc: DocumentMetadata): string => {
|
||||
const kind = doc.kind ?? doc["document-type"] ?? "";
|
||||
if (kind.includes("pdf")) return "PDF";
|
||||
if (kind.includes("text") || kind.includes("plain")) return "Text";
|
||||
if (kind.includes("html")) return "HTML";
|
||||
if (kind.includes("json")) return "JSON";
|
||||
return kind || "--";
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
{/* Header */}
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<LibraryBig className="h-6 w-6 text-brand-400" />
|
||||
<h1 className="text-2xl font-bold text-fg">Library</h1>
|
||||
<span className="ml-2 rounded bg-surface-200 px-2 py-0.5 text-xs text-fg-subtle">
|
||||
{collection}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={handleRefresh}
|
||||
disabled={loading}
|
||||
className="flex items-center gap-1.5 rounded-lg border border-border px-3 py-2 text-sm text-fg-muted transition-colors hover:bg-surface-200 disabled:opacity-40"
|
||||
>
|
||||
<RefreshCw className={cn("h-3.5 w-3.5", loading && "animate-spin")} />
|
||||
Refresh
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setUploadOpen(true)}
|
||||
className="flex items-center gap-2 rounded-lg bg-brand-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-brand-500"
|
||||
>
|
||||
<Upload className="h-4 w-4" />
|
||||
Upload
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Processing status */}
|
||||
{processing.length > 0 && (
|
||||
<div className="mb-4 rounded-lg border border-brand-500/30 bg-brand-500/5 p-3">
|
||||
<div className="mb-2 flex items-center gap-2 text-sm font-medium text-brand-300">
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
Processing ({processing.length})
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
{processing.map((p) => (
|
||||
<div key={p.id} className="flex items-center gap-2 text-xs text-fg-muted">
|
||||
<FileType2 className="h-3 w-3" />
|
||||
<span className="truncate">{p["document-id"] || p.id}</span>
|
||||
<Badge variant="info" className="ml-auto">
|
||||
{p.flow || "processing"}
|
||||
</Badge>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Content */}
|
||||
{loading && documents.length === 0 && (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="mr-2 h-5 w-5 animate-spin text-fg-subtle" />
|
||||
<span className="text-fg-subtle">Loading documents...</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<p className="py-8 text-center text-error">Error: {error}</p>
|
||||
)}
|
||||
|
||||
{!loading && !error && documents.length === 0 && (
|
||||
<div className="flex flex-1 flex-col items-center justify-center">
|
||||
<LibraryBig className="mb-3 h-10 w-10 text-fg-subtle opacity-30" />
|
||||
<p className="text-fg-subtle">
|
||||
No documents yet. Upload one to get started.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{documents.length > 0 && (
|
||||
<div className="overflow-x-auto rounded-lg border border-border">
|
||||
<table className="w-full text-left text-sm">
|
||||
<thead className="border-b border-border bg-surface-100 text-fg-muted">
|
||||
<tr>
|
||||
<th className="px-4 py-3 font-medium">Title</th>
|
||||
<th className="px-4 py-3 font-medium">Type</th>
|
||||
<th className="px-4 py-3 font-medium">Tags</th>
|
||||
<th className="px-4 py-3 font-medium">ID</th>
|
||||
<th className="px-4 py-3 font-medium text-right">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{documents.map((doc) => (
|
||||
<tr key={doc.id} className="hover:bg-surface-100/50">
|
||||
<td className="px-4 py-3 text-fg">
|
||||
<div className="flex items-center gap-2">
|
||||
<FileText className="h-4 w-4 shrink-0 text-fg-subtle" />
|
||||
{doc.title || "Untitled"}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<Badge variant="default">{guessKind(doc)}</Badge>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{(doc.tags ?? []).map((tag) => (
|
||||
<Badge key={tag} variant="info">{tag}</Badge>
|
||||
))}
|
||||
{(!doc.tags || doc.tags.length === 0) && (
|
||||
<span className="text-fg-subtle">--</span>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="max-w-[12rem] truncate px-4 py-3 font-mono text-xs text-fg-subtle">
|
||||
{doc.id}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
<button
|
||||
onClick={() => setDeleteTarget(doc)}
|
||||
className="rounded p-1.5 text-fg-subtle hover:bg-error/10 hover:text-error"
|
||||
title="Delete document"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Dialogs */}
|
||||
<UploadDialog
|
||||
open={uploadOpen}
|
||||
onClose={() => setUploadOpen(false)}
|
||||
onUpload={handleUpload}
|
||||
/>
|
||||
|
||||
<ConfirmDeleteDialog
|
||||
open={deleteTarget != null}
|
||||
docTitle={deleteTarget?.title ?? deleteTarget?.id ?? ""}
|
||||
onClose={() => setDeleteTarget(null)}
|
||||
onConfirm={handleDelete}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function fileToBase64(file: File): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
const result = reader.result as string;
|
||||
// Strip the data URL prefix (e.g. "data:application/pdf;base64,")
|
||||
const base64 = result.includes(",") ? result.split(",")[1]! : result;
|
||||
resolve(base64);
|
||||
};
|
||||
reader.onerror = reject;
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
}
|
||||
340
ts/packages/workbench/src/pages/settings.tsx
Normal file
340
ts/packages/workbench/src/pages/settings.tsx
Normal file
|
|
@ -0,0 +1,340 @@
|
|||
import { useCallback, useEffect, useState } from "react";
|
||||
import {
|
||||
Settings as SettingsIcon,
|
||||
Wifi,
|
||||
WifiOff,
|
||||
Key,
|
||||
Eye,
|
||||
EyeOff,
|
||||
Database,
|
||||
Workflow,
|
||||
Info,
|
||||
Loader2,
|
||||
Moon,
|
||||
Sun,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useSettings } from "@/providers/settings-provider";
|
||||
import { useSocket } from "@/providers/socket-provider";
|
||||
import { useConnectionState } from "@/providers/socket-provider";
|
||||
import { useFlows } from "@/hooks/use-flows";
|
||||
import { useSessionStore } from "@/hooks/use-session-store";
|
||||
import { useNotification } from "@/providers/notification-provider";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Section wrapper
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function Section({
|
||||
title,
|
||||
icon,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
icon: React.ReactNode;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="rounded-lg border border-border bg-surface-50 p-5">
|
||||
<h2 className="mb-4 flex items-center gap-2 text-sm font-semibold text-fg">
|
||||
{icon}
|
||||
{title}
|
||||
</h2>
|
||||
<div className="space-y-4">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Settings page
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export default function SettingsPage() {
|
||||
const { settings, updateSetting } = useSettings();
|
||||
const connectionState = useConnectionState();
|
||||
const socket = useSocket();
|
||||
const { flows } = useFlows();
|
||||
const notify = useNotification();
|
||||
|
||||
const flowId = useSessionStore((s) => s.flowId);
|
||||
const setFlowId = useSessionStore((s) => s.setFlowId);
|
||||
|
||||
const [showApiKey, setShowApiKey] = useState(false);
|
||||
const [collections, setCollections] = useState<
|
||||
Array<{ id?: string; name?: string; [key: string]: unknown }>
|
||||
>([]);
|
||||
const [loadingCollections, setLoadingCollections] = useState(false);
|
||||
|
||||
// Dark mode toggle -- uses a class on <html> and persists to localStorage
|
||||
const [isDark, setIsDark] = useState(() => {
|
||||
if (typeof window === "undefined") return true;
|
||||
return !document.documentElement.classList.contains("light");
|
||||
});
|
||||
|
||||
const toggleTheme = useCallback(() => {
|
||||
const next = !isDark;
|
||||
setIsDark(next);
|
||||
if (next) {
|
||||
document.documentElement.classList.remove("light");
|
||||
localStorage.setItem("tg-theme", "dark");
|
||||
} else {
|
||||
document.documentElement.classList.add("light");
|
||||
localStorage.setItem("tg-theme", "light");
|
||||
}
|
||||
}, [isDark]);
|
||||
|
||||
// Fetch collections
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setLoadingCollections(true);
|
||||
socket
|
||||
.collectionManagement()
|
||||
.listCollections()
|
||||
.then((cols) => {
|
||||
if (!cancelled) {
|
||||
setCollections(
|
||||
Array.isArray(cols)
|
||||
? (cols as Array<{ id?: string; name?: string; [key: string]: unknown }>)
|
||||
: [],
|
||||
);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
/* silent -- collections endpoint may not be available */
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoadingCollections(false);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [socket]);
|
||||
|
||||
// Connection status helpers
|
||||
const isConnected =
|
||||
connectionState.status === "connected" ||
|
||||
connectionState.status === "authenticated" ||
|
||||
connectionState.status === "unauthenticated";
|
||||
|
||||
const statusBadge = isConnected ? (
|
||||
<Badge variant="success">
|
||||
<Wifi className="h-3 w-3" /> {connectionState.status}
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="error">
|
||||
<WifiOff className="h-3 w-3" /> {connectionState.status}
|
||||
</Badge>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
{/* Header */}
|
||||
<div className="mb-6 flex items-center gap-3">
|
||||
<SettingsIcon className="h-6 w-6 text-brand-400" />
|
||||
<h1 className="text-2xl font-bold text-fg">Settings</h1>
|
||||
</div>
|
||||
|
||||
{/* Form */}
|
||||
<div className="max-w-2xl space-y-5">
|
||||
{/* Connection */}
|
||||
<Section
|
||||
title="Connection"
|
||||
icon={<Wifi className="h-4 w-4 text-fg-subtle" />}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-sm text-fg-muted">Status:</span>
|
||||
{statusBadge}
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label className="block text-sm font-medium text-fg-muted">
|
||||
Gateway URL
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={settings.gatewayUrl}
|
||||
onChange={(e) => updateSetting("gatewayUrl", e.target.value)}
|
||||
placeholder="Leave blank to use the default proxy"
|
||||
className="w-full rounded-lg border border-border bg-surface-100 px-4 py-2 text-sm text-fg placeholder:text-fg-subtle focus:border-brand-500 focus:outline-none focus:ring-1 focus:ring-brand-500"
|
||||
/>
|
||||
<p className="text-xs text-fg-subtle">
|
||||
The WebSocket URL for the TrustGraph gateway.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label className="block text-sm font-medium text-fg-muted">
|
||||
User ID
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={settings.user}
|
||||
onChange={(e) => updateSetting("user", e.target.value)}
|
||||
className="w-full rounded-lg border border-border bg-surface-100 px-4 py-2 text-sm text-fg placeholder:text-fg-subtle focus:border-brand-500 focus:outline-none focus:ring-1 focus:ring-brand-500"
|
||||
/>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{/* Authentication */}
|
||||
<Section
|
||||
title="Authentication"
|
||||
icon={<Key className="h-4 w-4 text-fg-subtle" />}
|
||||
>
|
||||
<div className="space-y-1.5">
|
||||
<label className="block text-sm font-medium text-fg-muted">
|
||||
API Key
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showApiKey ? "text" : "password"}
|
||||
value={settings.apiKey}
|
||||
onChange={(e) => updateSetting("apiKey", e.target.value)}
|
||||
placeholder="Leave blank for unauthenticated access"
|
||||
className="w-full rounded-lg border border-border bg-surface-100 px-4 py-2 pr-10 text-sm text-fg placeholder:text-fg-subtle focus:border-brand-500 focus:outline-none focus:ring-1 focus:ring-brand-500"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowApiKey((p) => !p)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-fg-subtle hover:text-fg"
|
||||
>
|
||||
{showApiKey ? (
|
||||
<EyeOff className="h-4 w-4" />
|
||||
) : (
|
||||
<Eye className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-xs text-fg-subtle">
|
||||
Changing the API key will reconnect the WebSocket.
|
||||
</p>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{/* Collection */}
|
||||
<Section
|
||||
title="Collection"
|
||||
icon={<Database className="h-4 w-4 text-fg-subtle" />}
|
||||
>
|
||||
<div className="space-y-1.5">
|
||||
<label className="block text-sm font-medium text-fg-muted">
|
||||
Active Collection
|
||||
</label>
|
||||
{loadingCollections ? (
|
||||
<div className="flex items-center gap-2 py-2 text-xs text-fg-subtle">
|
||||
<Loader2 className="h-3 w-3 animate-spin" /> Loading
|
||||
collections...
|
||||
</div>
|
||||
) : collections.length > 0 ? (
|
||||
<select
|
||||
value={settings.collection}
|
||||
onChange={(e) => updateSetting("collection", e.target.value)}
|
||||
className="w-full rounded-lg border border-border bg-surface-100 px-4 py-2 text-sm text-fg focus:border-brand-500 focus:outline-none focus:ring-1 focus:ring-brand-500"
|
||||
>
|
||||
{collections.map((c) => {
|
||||
const id = c.id ?? String(c.name ?? c);
|
||||
return (
|
||||
<option key={id} value={id}>
|
||||
{c.name ?? id}
|
||||
</option>
|
||||
);
|
||||
})}
|
||||
</select>
|
||||
) : (
|
||||
<input
|
||||
type="text"
|
||||
value={settings.collection}
|
||||
onChange={(e) => updateSetting("collection", e.target.value)}
|
||||
className="w-full rounded-lg border border-border bg-surface-100 px-4 py-2 text-sm text-fg placeholder:text-fg-subtle focus:border-brand-500 focus:outline-none focus:ring-1 focus:ring-brand-500"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{/* Flow */}
|
||||
<Section
|
||||
title="Active Flow"
|
||||
icon={<Workflow className="h-4 w-4 text-fg-subtle" />}
|
||||
>
|
||||
<div className="space-y-1.5">
|
||||
<label className="block text-sm font-medium text-fg-muted">
|
||||
Flow
|
||||
</label>
|
||||
{flows.length > 0 ? (
|
||||
<select
|
||||
value={flowId}
|
||||
onChange={(e) => setFlowId(e.target.value)}
|
||||
className="w-full rounded-lg border border-border bg-surface-100 px-4 py-2 text-sm text-fg focus:border-brand-500 focus:outline-none focus:ring-1 focus:ring-brand-500"
|
||||
>
|
||||
<option value="default">default</option>
|
||||
{flows.map((f) => (
|
||||
<option key={f.id} value={f.id}>
|
||||
{f.id}
|
||||
{f.description ? ` -- ${f.description}` : ""}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
) : (
|
||||
<input
|
||||
type="text"
|
||||
value={flowId}
|
||||
onChange={(e) => setFlowId(e.target.value)}
|
||||
placeholder="default"
|
||||
className="w-full rounded-lg border border-border bg-surface-100 px-4 py-2 text-sm text-fg placeholder:text-fg-subtle focus:border-brand-500 focus:outline-none focus:ring-1 focus:ring-brand-500"
|
||||
/>
|
||||
)}
|
||||
<p className="text-xs text-fg-subtle">
|
||||
The flow ID used for chat, graph queries, and document processing.
|
||||
</p>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{/* Theme */}
|
||||
<Section
|
||||
title="Appearance"
|
||||
icon={isDark ? <Moon className="h-4 w-4 text-fg-subtle" /> : <Sun className="h-4 w-4 text-fg-subtle" />}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-fg">Theme</p>
|
||||
<p className="text-xs text-fg-subtle">
|
||||
Toggle between dark and light mode.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={toggleTheme}
|
||||
className={cn(
|
||||
"relative inline-flex h-6 w-11 items-center rounded-full transition-colors",
|
||||
isDark ? "bg-brand-600" : "bg-surface-300",
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"inline-block h-4 w-4 rounded-full bg-white transition-transform",
|
||||
isDark ? "translate-x-6" : "translate-x-1",
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{/* About */}
|
||||
<Section
|
||||
title="About"
|
||||
icon={<Info className="h-4 w-4 text-fg-subtle" />}
|
||||
>
|
||||
<div className="space-y-2 text-sm text-fg-muted">
|
||||
<p>
|
||||
<span className="font-medium text-fg">TrustGraph Workbench</span>{" "}
|
||||
v0.1.0
|
||||
</p>
|
||||
<p>
|
||||
A web-based interface for interacting with the TrustGraph
|
||||
knowledge-graph system.
|
||||
</p>
|
||||
</div>
|
||||
</Section>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue