mirror of
https://github.com/trustgraph-ai/trustgraph.git
synced 2026-07-02 14:31:01 +02:00
Advance TS port Effect workbench
This commit is contained in:
parent
92dae8c374
commit
3515106670
116 changed files with 12286 additions and 9584 deletions
|
|
@ -1,10 +1,5 @@
|
|||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
type KeyboardEvent,
|
||||
} from "react";
|
||||
import { useAtom, useAtomSet, useAtomValue } from "@effect/atom-react";
|
||||
import type { KeyboardEvent } from "react";
|
||||
import {
|
||||
MessageSquareText,
|
||||
Send,
|
||||
|
|
@ -20,47 +15,49 @@ import {
|
|||
} from "lucide-react";
|
||||
import Markdown from "react-markdown";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useConversation, type ChatMessage } from "@/hooks/use-conversation";
|
||||
import { useChat } from "@/hooks/use-chat";
|
||||
import { useSettings } from "@/providers/settings-provider";
|
||||
import { useProgressStore } from "@/hooks/use-progress-store";
|
||||
import {
|
||||
agentPhaseExpandedAtom,
|
||||
cancelChatAtom,
|
||||
clearMessagesAtom,
|
||||
conversationAtom,
|
||||
deleteMessageAtom,
|
||||
isLoadingAtom,
|
||||
regenerateLastMessageAtom,
|
||||
setChatModeAtom,
|
||||
setConversationInputAtom,
|
||||
settingsAtom,
|
||||
submitMessageAtom,
|
||||
type ChatMessage,
|
||||
} from "@/atoms/workbench";
|
||||
import { AutoTextarea } from "@/components/ui/textarea";
|
||||
import { MessageActions } from "@/components/chat/message-actions";
|
||||
import { ExplainGraph } from "@/components/chat/explain-graph";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const MODES = [
|
||||
{ value: "graph-rag" as const, label: "Graph RAG" },
|
||||
{ value: "document-rag" as const, label: "Doc RAG" },
|
||||
{ value: "agent" as const, label: "Agent" },
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Agent phase section (collapsible)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function AgentPhaseBlock({
|
||||
messageId,
|
||||
phase,
|
||||
icon,
|
||||
label,
|
||||
content,
|
||||
isActive,
|
||||
}: {
|
||||
messageId: string;
|
||||
phase: string;
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
content: string;
|
||||
isActive: boolean;
|
||||
}) {
|
||||
const [manualToggle, setManualToggle] = useState<boolean | null>(null);
|
||||
|
||||
const [expandedMap, setExpandedMap] = useAtom(agentPhaseExpandedAtom);
|
||||
const key = `${messageId}:${phase}`;
|
||||
if (content.length === 0 && !isActive) return null;
|
||||
|
||||
// Auto-expand while actively streaming; user can override
|
||||
const expanded = manualToggle ?? isActive;
|
||||
const expanded = expandedMap[key] ?? isActive;
|
||||
|
||||
const phaseColors: Record<string, string> = {
|
||||
think: "border-amber-500/30 bg-amber-500/5",
|
||||
|
|
@ -75,40 +72,22 @@ function AgentPhaseBlock({
|
|||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-md border",
|
||||
phaseColors[phase] ?? "border-border bg-surface-100",
|
||||
)}
|
||||
>
|
||||
<div className={cn("rounded-md border", phaseColors[phase] ?? "border-border bg-surface-100")}>
|
||||
<button
|
||||
onClick={() => setManualToggle((prev) => !(prev ?? isActive))}
|
||||
onClick={() => setExpandedMap({ ...expandedMap, [key]: !expanded })}
|
||||
aria-expanded={expanded}
|
||||
className="flex w-full items-center gap-2 px-3 py-2 text-left text-xs font-medium text-fg-muted"
|
||||
>
|
||||
{expanded ? (
|
||||
<ChevronDown className="h-3 w-3 shrink-0" />
|
||||
) : (
|
||||
<ChevronRight className="h-3 w-3 shrink-0" />
|
||||
)}
|
||||
{expanded ? <ChevronDown className="h-3 w-3 shrink-0" /> : <ChevronRight className="h-3 w-3 shrink-0" />}
|
||||
{icon}
|
||||
<span
|
||||
className={cn(
|
||||
"rounded px-1.5 py-0.5",
|
||||
badgeColors[phase] ?? "bg-surface-200 text-fg-muted",
|
||||
)}
|
||||
>
|
||||
<span className={cn("rounded px-1.5 py-0.5", badgeColors[phase] ?? "bg-surface-200 text-fg-muted")}>
|
||||
{label}
|
||||
</span>
|
||||
{isActive && (
|
||||
<Loader2 className="ml-auto h-3 w-3 animate-spin text-fg-subtle" />
|
||||
)}
|
||||
{isActive && <Loader2 className="ml-auto h-3 w-3 animate-spin text-fg-subtle" />}
|
||||
</button>
|
||||
{expanded && (content.length > 0 || isActive) && (
|
||||
<div className="border-t border-border/50 px-3 py-2 text-xs leading-relaxed text-fg-muted">
|
||||
<p className="whitespace-pre-wrap">
|
||||
{content.length > 0 ? content : isActive ? "..." : ""}
|
||||
</p>
|
||||
<p className="whitespace-pre-wrap">{content.length > 0 ? content : isActive ? "..." : ""}</p>
|
||||
{isActive && content.length > 0 && (
|
||||
<span className="mt-1 inline-block h-1.5 w-1.5 animate-pulse rounded-full bg-amber-400" />
|
||||
)}
|
||||
|
|
@ -118,168 +97,146 @@ function AgentPhaseBlock({
|
|||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Single message bubble
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function MessageBubble({ msg, collection }: { msg: ChatMessage; collection: string }) {
|
||||
function MessageBubble({
|
||||
msg,
|
||||
collection,
|
||||
isLastAssistant,
|
||||
}: {
|
||||
msg: ChatMessage;
|
||||
collection: string;
|
||||
isLastAssistant: boolean;
|
||||
}) {
|
||||
const deleteMessage = useAtomSet(deleteMessageAtom);
|
||||
const regenerateLastMessage = useAtomSet(regenerateLastMessageAtom);
|
||||
const isUser = msg.role === "user";
|
||||
const agentPhases = msg.agentPhases;
|
||||
const isError = !isUser && msg.content.startsWith("Error:");
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-lg px-4 py-3 text-sm leading-relaxed",
|
||||
isUser
|
||||
? "ml-auto max-w-[80%] bg-brand-700/30 text-fg"
|
||||
: isError
|
||||
? "mr-auto max-w-[80%] border border-error/30 bg-error/10 text-error"
|
||||
: "mr-auto max-w-[80%] bg-surface-100 text-fg",
|
||||
)}
|
||||
>
|
||||
{/* Agent phase blocks (only for agent messages) */}
|
||||
{agentPhases !== undefined && (
|
||||
<div className="mb-2 space-y-1.5">
|
||||
<AgentPhaseBlock
|
||||
phase="think"
|
||||
icon={<Brain className="h-3 w-3" />}
|
||||
label="Thinking"
|
||||
content={agentPhases.think}
|
||||
isActive={msg.activePhase === "think"}
|
||||
/>
|
||||
<AgentPhaseBlock
|
||||
phase="observe"
|
||||
icon={<Eye className="h-3 w-3" />}
|
||||
label="Observing"
|
||||
content={agentPhases.observe}
|
||||
isActive={msg.activePhase === "observe"}
|
||||
/>
|
||||
{agentPhases.answer.length > 0 && (
|
||||
<div className="flex items-center gap-1.5 px-1 pt-1 text-xs text-emerald-400">
|
||||
<CheckCircle className="h-3 w-3" />
|
||||
<span className="font-medium">Answer</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="group relative">
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-lg px-4 py-3 text-sm leading-relaxed",
|
||||
isUser
|
||||
? "ml-auto max-w-[80%] bg-brand-700/30 text-fg"
|
||||
: isError
|
||||
? "mr-auto max-w-[80%] border border-error/30 bg-error/10 text-error"
|
||||
: "mr-auto max-w-[80%] bg-surface-100 text-fg",
|
||||
)}
|
||||
>
|
||||
{agentPhases !== undefined && (
|
||||
<div className="mb-2 space-y-1.5">
|
||||
<AgentPhaseBlock
|
||||
messageId={msg.id}
|
||||
phase="think"
|
||||
icon={<Brain className="h-3 w-3" />}
|
||||
label="Thinking"
|
||||
content={agentPhases.think}
|
||||
isActive={msg.activePhase === "think"}
|
||||
/>
|
||||
<AgentPhaseBlock
|
||||
messageId={msg.id}
|
||||
phase="observe"
|
||||
icon={<Eye className="h-3 w-3" />}
|
||||
label="Observing"
|
||||
content={agentPhases.observe}
|
||||
isActive={msg.activePhase === "observe"}
|
||||
/>
|
||||
{agentPhases.answer.length > 0 && (
|
||||
<div className="flex items-center gap-1.5 px-1 pt-1 text-xs text-emerald-400">
|
||||
<CheckCircle className="h-3 w-3" />
|
||||
<span className="font-medium">Answer</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Main content (markdown for assistant, plain for user) */}
|
||||
{isUser ? (
|
||||
<p className="whitespace-pre-wrap">{msg.content}</p>
|
||||
) : isError ? (
|
||||
<div className="flex items-start gap-2">
|
||||
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" />
|
||||
{isUser ? (
|
||||
<p className="whitespace-pre-wrap">{msg.content}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="prose prose-sm max-w-none text-fg prose-headings:text-fg prose-strong:text-fg prose-p:my-1 prose-a:text-brand-400 prose-pre:bg-surface-200 prose-pre:text-fg prose-code:text-brand-300">
|
||||
<Markdown>{msg.content.length > 0 ? msg.content : msg.isStreaming === true ? "" : "(empty)"}</Markdown>
|
||||
</div>
|
||||
)}
|
||||
) : isError ? (
|
||||
<div className="flex items-start gap-2">
|
||||
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" />
|
||||
<p className="whitespace-pre-wrap">{msg.content}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="prose prose-sm max-w-none text-fg prose-headings:text-fg prose-strong:text-fg prose-p:my-1 prose-a:text-brand-400 prose-pre:bg-surface-200 prose-pre:text-fg prose-code:text-brand-300">
|
||||
<Markdown>{msg.content.length > 0 ? msg.content : msg.isStreaming === true ? "" : "(empty)"}</Markdown>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Streaming indicator */}
|
||||
{msg.isStreaming === true && (
|
||||
<span className="mt-1 inline-block h-2 w-2 animate-pulse rounded-full bg-brand-400" />
|
||||
)}
|
||||
{msg.isStreaming === true && (
|
||||
<span className="mt-1 inline-block h-2 w-2 animate-pulse rounded-full bg-brand-400" />
|
||||
)}
|
||||
|
||||
{/* Token metadata */}
|
||||
{msg.metadata !== undefined && (
|
||||
<div className="mt-2 flex items-center gap-3 text-[10px] text-fg-subtle">
|
||||
{msg.metadata.model !== undefined && msg.metadata.model.length > 0 && <span>{msg.metadata.model}</span>}
|
||||
{msg.metadata.inTokens != null && (
|
||||
<span>in: {msg.metadata.inTokens}</span>
|
||||
)}
|
||||
{msg.metadata.outTokens != null && (
|
||||
<span>out: {msg.metadata.outTokens}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{msg.metadata !== undefined && (
|
||||
<div className="mt-2 flex items-center gap-3 text-[10px] text-fg-subtle">
|
||||
{msg.metadata.model !== undefined && msg.metadata.model.length > 0 && <span>{msg.metadata.model}</span>}
|
||||
{msg.metadata.inTokens != null && <span>in: {msg.metadata.inTokens}</span>}
|
||||
{msg.metadata.outTokens != null && <span>out: {msg.metadata.outTokens}</span>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Explainability graph */}
|
||||
{!isUser && !isError && msg.isStreaming !== true && msg.explainEvents !== undefined && msg.explainEvents.length > 0 && (
|
||||
<ExplainGraph explainEvents={msg.explainEvents} collection={collection} />
|
||||
{!isUser && !isError && msg.isStreaming !== true && msg.explainEvents !== undefined && msg.explainEvents.length > 0 && (
|
||||
<ExplainGraph explainEvents={msg.explainEvents} collection={collection} />
|
||||
)}
|
||||
</div>
|
||||
{!isUser && (
|
||||
<MessageActions
|
||||
messageId={msg.id}
|
||||
content={msg.content}
|
||||
isLastAssistant={isLastAssistant}
|
||||
onDelete={() => deleteMessage(msg.id)}
|
||||
onRegenerate={() => regenerateLastMessage()}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Chat page
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export default function ChatPage() {
|
||||
const messages = useConversation((s) => s.messages);
|
||||
const input = useConversation((s) => s.input);
|
||||
const chatMode = useConversation((s) => s.chatMode);
|
||||
const setInput = useConversation((s) => s.setInput);
|
||||
const setChatMode = useConversation((s) => s.setChatMode);
|
||||
const clearMessages = useConversation((s) => s.clearMessages);
|
||||
const { submitMessage, cancelRequest, regenerateLastMessage } = useChat();
|
||||
const deleteMessage = useConversation((s) => s.deleteMessage);
|
||||
const collection = useSettings((s) => s.settings.collection);
|
||||
const isLoading = useProgressStore((s) => s.isLoading);
|
||||
const conversation = useAtomValue(conversationAtom);
|
||||
const collection = useAtomValue(settingsAtom).collection;
|
||||
const isLoading = useAtomValue(isLoadingAtom);
|
||||
const setInput = useAtomSet(setConversationInputAtom);
|
||||
const setChatMode = useAtomSet(setChatModeAtom);
|
||||
const clearMessages = useAtomSet(clearMessagesAtom);
|
||||
const submitMessage = useAtomSet(submitMessageAtom);
|
||||
const cancelRequest = useAtomSet(cancelChatAtom);
|
||||
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Elapsed time counter while loading
|
||||
const [elapsed, setElapsed] = useState(0);
|
||||
useEffect(() => {
|
||||
if (!isLoading) {
|
||||
setElapsed(0);
|
||||
return;
|
||||
const handleSubmit = () => {
|
||||
if (conversation.input.trim().length > 0) {
|
||||
submitMessage({ input: conversation.input });
|
||||
}
|
||||
const interval = setInterval(() => setElapsed((e) => e + 1), 1000);
|
||||
return () => clearInterval(interval);
|
||||
}, [isLoading]);
|
||||
};
|
||||
|
||||
// Auto-scroll to bottom when messages change
|
||||
useEffect(() => {
|
||||
scrollRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||
}, [messages]);
|
||||
|
||||
const handleSubmit = useCallback(() => {
|
||||
if (input.trim().length > 0) {
|
||||
submitMessage({ input });
|
||||
const handleKeyDown = (event: KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (event.key === "Enter" && !event.shiftKey) {
|
||||
event.preventDefault();
|
||||
handleSubmit();
|
||||
}
|
||||
}, [input, submitMessage]);
|
||||
};
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSubmit();
|
||||
}
|
||||
},
|
||||
[handleSubmit],
|
||||
);
|
||||
const lastAssistantId = [...conversation.messages].reverse().find((message) => message.role === "assistant")?.id;
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
{/* Header */}
|
||||
<div className="mb-4 flex flex-wrap items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-3">
|
||||
<MessageSquareText className="h-6 w-6 text-brand-400" />
|
||||
<h1 className="text-2xl font-bold text-fg">Chat</h1>
|
||||
<span className="ml-2 rounded bg-surface-200 px-2 py-0.5 text-xs text-fg-muted">
|
||||
{collection}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{/* Mode selector */}
|
||||
<div role="group" aria-label="Chat mode" className="flex rounded-lg border border-border bg-surface-100 p-0.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex rounded-lg border border-border bg-surface-100 p-1">
|
||||
{MODES.map((mode) => (
|
||||
<button
|
||||
type="button"
|
||||
key={mode.value}
|
||||
onClick={() => setChatMode(mode.value)}
|
||||
aria-pressed={chatMode === mode.value}
|
||||
className={cn(
|
||||
"rounded-md px-3 py-1 text-xs font-medium transition-colors",
|
||||
chatMode === mode.value
|
||||
"rounded-md px-3 py-1.5 text-xs font-medium transition-colors",
|
||||
conversation.chatMode === mode.value
|
||||
? "bg-brand-600 text-white"
|
||||
: "text-fg-muted hover:text-fg",
|
||||
: "text-fg-muted hover:bg-surface-200 hover:text-fg",
|
||||
)}
|
||||
>
|
||||
{mode.label}
|
||||
|
|
@ -287,84 +244,68 @@ export default function ChatPage() {
|
|||
))}
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => { cancelRequest(); clearMessages(); }}
|
||||
className="rounded-lg p-2 text-fg-subtle hover:bg-surface-200 hover:text-fg"
|
||||
title="Clear messages"
|
||||
aria-label="Clear messages"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
{conversation.messages.length > 0 && (
|
||||
<button
|
||||
onClick={() => clearMessages(null)}
|
||||
className="rounded-lg border border-border p-2 text-fg-subtle hover:bg-surface-200 hover:text-error"
|
||||
aria-label="Clear conversation"
|
||||
title="Clear conversation"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Messages */}
|
||||
<div className="flex-1 space-y-4 overflow-y-auto pb-4 pt-10">
|
||||
{messages.length === 0 && (
|
||||
<div className="flex flex-col items-center justify-center py-20 text-fg-subtle">
|
||||
<MessageSquareText className="mb-3 h-10 w-10 opacity-30" />
|
||||
<p>Send a message to start a conversation.</p>
|
||||
<p className="mt-1 text-xs">
|
||||
Mode: <span className="text-fg-muted">{MODES.find((m) => m.value === chatMode)?.label ?? chatMode}</span>
|
||||
</p>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto rounded-lg border border-border bg-surface-50 p-4">
|
||||
{conversation.messages.length === 0 ? (
|
||||
<div className="flex h-full flex-col items-center justify-center text-center">
|
||||
<MessageSquareText className="mb-3 h-10 w-10 text-fg-subtle opacity-30" />
|
||||
<p className="text-sm text-fg-subtle">Start a conversation with TrustGraph.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{conversation.messages.map((message) => (
|
||||
<MessageBubble
|
||||
key={message.id}
|
||||
msg={message}
|
||||
collection={collection}
|
||||
isLastAssistant={message.id === lastAssistantId}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{messages.map((msg, idx) => {
|
||||
const isLastAssistant =
|
||||
msg.role === "assistant" &&
|
||||
idx === messages.length - 1;
|
||||
|
||||
return (
|
||||
<div key={msg.id} className="group relative">
|
||||
{msg.isStreaming !== true && (
|
||||
<MessageActions
|
||||
content={msg.content}
|
||||
isLastAssistant={isLastAssistant}
|
||||
onDelete={() => deleteMessage(msg.id)}
|
||||
{...(isLastAssistant ? { onRegenerate: regenerateLastMessage } : {})}
|
||||
/>
|
||||
)}
|
||||
<MessageBubble msg={msg} collection={collection} />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<div ref={scrollRef} />
|
||||
</div>
|
||||
|
||||
{/* Loading indicator */}
|
||||
{isLoading && (
|
||||
<div className="flex items-center gap-2 pb-2 text-xs text-fg-subtle">
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
<span>Processing... {elapsed}s</span>
|
||||
<button
|
||||
onClick={cancelRequest}
|
||||
className="flex items-center gap-1 rounded-lg px-3 py-1 text-xs text-red-400 hover:bg-surface-200"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
Cancel
|
||||
</button>
|
||||
<div className="mt-4 rounded-lg border border-border bg-surface-50 p-3">
|
||||
<div className="flex items-end gap-2">
|
||||
<AutoTextarea
|
||||
value={conversation.input}
|
||||
onChange={(event) => setInput(event.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={`Ask using ${MODES.find((mode) => mode.value === conversation.chatMode)?.label ?? "TrustGraph"}...`}
|
||||
disabled={isLoading}
|
||||
maxRows={8}
|
||||
/>
|
||||
{isLoading ? (
|
||||
<button
|
||||
onClick={() => cancelRequest(null)}
|
||||
className="rounded-lg border border-border p-3 text-fg-muted hover:bg-error/10 hover:text-error"
|
||||
aria-label="Cancel request"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={conversation.input.trim().length === 0}
|
||||
className="rounded-lg bg-brand-600 p-3 text-white hover:bg-brand-500 disabled:opacity-40"
|
||||
aria-label="Send message"
|
||||
>
|
||||
<Send className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Input area */}
|
||||
<div className="flex items-end gap-2 border-t border-border pt-4">
|
||||
<AutoTextarea
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Type your message... (Enter to send, Shift+Enter for new line)"
|
||||
aria-label="Chat message"
|
||||
maxRows={6}
|
||||
/>
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={input.trim().length === 0 || isLoading}
|
||||
aria-label="Send message"
|
||||
className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-brand-600 text-white transition-colors hover:bg-brand-500 disabled:opacity-40"
|
||||
>
|
||||
<Send className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useEffect, useState } from "react";
|
||||
import { useAtom, useAtomRefresh, useAtomSet, useAtomValue } from "@effect/atom-react";
|
||||
import {
|
||||
Workflow,
|
||||
Plus,
|
||||
|
|
@ -7,579 +7,201 @@ import {
|
|||
ChevronDown,
|
||||
ChevronRight,
|
||||
Loader2,
|
||||
AlertTriangle,
|
||||
Info,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useFlows, type FlowSummary } from "@/hooks/use-flows";
|
||||
import { useSocket } from "@/providers/socket-provider";
|
||||
import { useNotification } from "@/providers/notification-provider";
|
||||
import {
|
||||
activeActionAtom,
|
||||
encodeJsonUnknownString,
|
||||
flowBlueprintAtom,
|
||||
flowBlueprintsAtom,
|
||||
flowExpandedAtom,
|
||||
flowsAtom,
|
||||
flowsStartDialogOpenAtom,
|
||||
parseJsonUnknown,
|
||||
resultData,
|
||||
resultError,
|
||||
resultLoading,
|
||||
startFlowAtom,
|
||||
startFlowFormAtom,
|
||||
stopFlowAtom,
|
||||
} from "@/atoms/workbench";
|
||||
import { Dialog } from "@/components/ui/dialog";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Start flow dialog
|
||||
// ---------------------------------------------------------------------------
|
||||
function StartFlowDialog() {
|
||||
const [open, setOpen] = useAtom(flowsStartDialogOpenAtom);
|
||||
const [form, setForm] = useAtom(startFlowFormAtom);
|
||||
const startFlow = useAtomSet(startFlowAtom);
|
||||
const blueprintsResult = useAtomValue(flowBlueprintsAtom);
|
||||
const blueprints = resultData(blueprintsResult, []);
|
||||
const blueprintDetail = resultData(useAtomValue(flowBlueprintAtom(form.blueprint)), null) as Record<string, unknown> | null;
|
||||
const loadingBlueprints = resultLoading(blueprintsResult, blueprints);
|
||||
const isValid = form.id.trim().length > 0 && form.blueprint.length > 0 && form.description.trim().length > 0;
|
||||
|
||||
function StartFlowDialog({
|
||||
open,
|
||||
onClose,
|
||||
onStart,
|
||||
}: {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onStart: (
|
||||
id: string,
|
||||
blueprint: string,
|
||||
description: string,
|
||||
params: Record<string, unknown>,
|
||||
) => Promise<void>;
|
||||
}) {
|
||||
const socket = useSocket();
|
||||
const [blueprints, setBlueprints] = useState<string[]>([]);
|
||||
const [loadingBlueprints, setLoadingBlueprints] = useState(false);
|
||||
const [id, setId] = useState("");
|
||||
const [blueprint, setBlueprint] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [paramsJson, setParamsJson] = useState("{}");
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [paramsError, setParamsError] = useState<string | null>(null);
|
||||
const [submitted, setSubmitted] = useState(false);
|
||||
const [blueprintDef, setBlueprintDef] = useState<Record<string, unknown> | null>(null);
|
||||
const [loadingDef, setLoadingDef] = useState(false);
|
||||
const [defExpanded, setDefExpanded] = useState(false);
|
||||
|
||||
// Fetch blueprints when dialog opens
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setLoadingBlueprints(true);
|
||||
socket
|
||||
.flows()
|
||||
.getFlowBlueprints()
|
||||
.then((names) => {
|
||||
const list = names ?? [];
|
||||
setBlueprints(list);
|
||||
if (list.length > 0 && blueprint.length === 0) {
|
||||
setBlueprint(list[0]!);
|
||||
}
|
||||
})
|
||||
.catch(() => setBlueprints([]))
|
||||
.finally(() => setLoadingBlueprints(false));
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [open, socket]);
|
||||
|
||||
// Fetch blueprint definition when selection changes
|
||||
useEffect(() => {
|
||||
if (blueprint.length === 0) {
|
||||
setBlueprintDef(null);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
setLoadingDef(true);
|
||||
setBlueprintDef(null);
|
||||
socket
|
||||
.flows()
|
||||
.getFlowBlueprint(blueprint)
|
||||
.then((def) => {
|
||||
if (cancelled) return;
|
||||
setBlueprintDef(def);
|
||||
// Pre-populate parameters with defaults from the definition
|
||||
const paramsDef =
|
||||
def?.parameters ?? def?.params ?? def?.["parameters"] ?? def?.["params"];
|
||||
if (paramsDef !== undefined && paramsDef !== null && typeof paramsDef === "object") {
|
||||
const defaults: Record<string, unknown> = {};
|
||||
const params = paramsDef as Record<string, unknown>;
|
||||
for (const [key, val] of Object.entries(params)) {
|
||||
if (val !== null && typeof val === "object" && "default" in (val as Record<string, unknown>)) {
|
||||
defaults[key] = (val as Record<string, unknown>).default;
|
||||
}
|
||||
}
|
||||
if (Object.keys(defaults).length > 0) {
|
||||
setParamsJson(JSON.stringify(defaults, null, 2));
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (cancelled === false) setBlueprintDef(null);
|
||||
})
|
||||
.finally(() => {
|
||||
if (cancelled === false) setLoadingDef(false);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [blueprint, socket]);
|
||||
|
||||
const reset = () => {
|
||||
setId("");
|
||||
setBlueprint("");
|
||||
setDescription("");
|
||||
setParamsJson("{}");
|
||||
setParamsError(null);
|
||||
setSubmitting(false);
|
||||
setSubmitted(false);
|
||||
setBlueprintDef(null);
|
||||
setLoadingDef(false);
|
||||
setDefExpanded(false);
|
||||
const close = () => {
|
||||
setForm({
|
||||
id: "",
|
||||
blueprint: "",
|
||||
description: "",
|
||||
paramsJson: "{}",
|
||||
submitting: false,
|
||||
paramsError: null,
|
||||
submitted: false,
|
||||
definitionExpanded: false,
|
||||
});
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
setSubmitted(true);
|
||||
if (!isValid) return;
|
||||
|
||||
let params: Record<string, unknown> = {};
|
||||
try {
|
||||
params = JSON.parse(paramsJson);
|
||||
setParamsError(null);
|
||||
} catch {
|
||||
setParamsError("Invalid JSON");
|
||||
return;
|
||||
}
|
||||
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await onStart(id, blueprint, description, params);
|
||||
reset();
|
||||
onClose();
|
||||
} catch {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const isValid = id.trim().length > 0 && blueprint.length > 0 && description.trim().length > 0;
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onClose={() => {
|
||||
if (!submitting) {
|
||||
reset();
|
||||
onClose();
|
||||
}
|
||||
}}
|
||||
onClose={close}
|
||||
title="Start Flow"
|
||||
footer={
|
||||
<>
|
||||
<button
|
||||
onClick={() => {
|
||||
reset();
|
||||
onClose();
|
||||
}}
|
||||
disabled={submitting}
|
||||
onClick={close}
|
||||
disabled={form.submitting}
|
||||
className="rounded-lg border border-border px-4 py-2 text-sm text-fg-muted hover:bg-surface-200 disabled:opacity-40"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={submitting}
|
||||
onClick={() => {
|
||||
setForm({ ...form, submitted: true });
|
||||
if (!isValid) return;
|
||||
const parameters = parseJsonUnknown(form.paramsJson);
|
||||
if (parameters === undefined || typeof parameters !== "object" || parameters === null || Array.isArray(parameters)) {
|
||||
setForm({ ...form, paramsError: "Invalid JSON", submitted: true });
|
||||
return;
|
||||
}
|
||||
startFlow({
|
||||
id: form.id.trim(),
|
||||
blueprint: form.blueprint,
|
||||
description: form.description.trim(),
|
||||
parameters: parameters as Record<string, unknown>,
|
||||
});
|
||||
close();
|
||||
}}
|
||||
disabled={form.submitting}
|
||||
className="flex items-center gap-2 rounded-lg bg-brand-600 px-4 py-2 text-sm font-medium text-white hover:bg-brand-500 disabled:opacity-40"
|
||||
>
|
||||
{submitting && <Loader2 className="h-3.5 w-3.5 animate-spin" />}
|
||||
{form.submitting && <Loader2 className="h-3.5 w-3.5 animate-spin" />}
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
Start
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{/* Flow ID */}
|
||||
<div className="mb-3 space-y-1.5">
|
||||
<label htmlFor="flow-id" className="block text-sm font-medium text-fg-muted">
|
||||
Flow ID <span className="text-error">*</span>
|
||||
<div className="space-y-3">
|
||||
<label className="block">
|
||||
<span className="mb-1 block text-sm font-medium text-fg-muted">Flow ID</span>
|
||||
<input
|
||||
value={form.id}
|
||||
onChange={(event) => setForm({ ...form, id: event.target.value })}
|
||||
placeholder="my-flow-id"
|
||||
className="w-full rounded-lg border border-border bg-surface-100 px-3 py-2 text-sm text-fg placeholder:text-fg-subtle focus:border-brand-500 focus:outline-none focus:ring-1 focus:ring-brand-500"
|
||||
/>
|
||||
{form.submitted && form.id.trim().length === 0 && (
|
||||
<p className="mt-1 text-xs text-red-400">Flow ID is required</p>
|
||||
)}
|
||||
</label>
|
||||
<input
|
||||
id="flow-id"
|
||||
type="text"
|
||||
value={id}
|
||||
onChange={(e) => setId(e.target.value)}
|
||||
placeholder="my-flow-id"
|
||||
className="w-full rounded-lg border border-border bg-surface-100 px-3 py-2 text-sm text-fg placeholder:text-fg-subtle focus:border-brand-500 focus:outline-none focus:ring-1 focus:ring-brand-500"
|
||||
/>
|
||||
{submitted && id.trim().length === 0 && (
|
||||
<p className="mt-1 text-xs text-red-400">Flow ID is required</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Blueprint name */}
|
||||
<div className="mb-3 space-y-1.5">
|
||||
<label htmlFor="flow-blueprint" className="block text-sm font-medium text-fg-muted">
|
||||
Blueprint <span className="text-error">*</span>
|
||||
</label>
|
||||
{loadingBlueprints ? (
|
||||
<div className="flex items-center gap-2 py-2 text-xs text-fg-subtle">
|
||||
<Loader2 className="h-3 w-3 animate-spin" /> Loading blueprints...
|
||||
</div>
|
||||
) : (
|
||||
<select
|
||||
id="flow-blueprint"
|
||||
value={blueprint}
|
||||
onChange={(e) => setBlueprint(e.target.value)}
|
||||
className="w-full rounded-lg border border-border bg-surface-100 px-3 py-2 text-sm text-fg focus:border-brand-500 focus:outline-none focus:ring-1 focus:ring-brand-500"
|
||||
>
|
||||
<option value="" disabled>
|
||||
Select a blueprint
|
||||
</option>
|
||||
{blueprints.map((bp) => (
|
||||
<option key={bp} value={bp}>
|
||||
{bp}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
{submitted && blueprint.length === 0 && (
|
||||
<p className="mt-1 text-xs text-red-400">Blueprint is required</p>
|
||||
)}
|
||||
|
||||
{/* Blueprint details info section */}
|
||||
{loadingDef && (
|
||||
<div className="mt-2 flex items-center gap-2 text-xs text-fg-subtle">
|
||||
<Loader2 className="h-3 w-3 animate-spin" /> Loading blueprint details...
|
||||
</div>
|
||||
)}
|
||||
|
||||
{blueprintDef !== null && !loadingDef && (
|
||||
<div className="mt-2 rounded-lg border border-border bg-surface-50 p-3">
|
||||
<div className="flex items-center gap-1.5 text-xs font-medium text-fg-muted">
|
||||
<Info className="h-3.5 w-3.5 text-brand-400" />
|
||||
Blueprint Details
|
||||
<label className="block">
|
||||
<span className="mb-1 block text-sm font-medium text-fg-muted">Blueprint</span>
|
||||
{loadingBlueprints ? (
|
||||
<div className="flex items-center gap-2 py-2 text-xs text-fg-subtle">
|
||||
<Loader2 className="h-3 w-3 animate-spin" /> Loading blueprints...
|
||||
</div>
|
||||
) : (
|
||||
<select
|
||||
value={form.blueprint}
|
||||
onChange={(event) => setForm({ ...form, blueprint: event.target.value })}
|
||||
className="w-full rounded-lg border border-border bg-surface-100 px-3 py-2 text-sm text-fg focus:border-brand-500 focus:outline-none focus:ring-1 focus:ring-brand-500"
|
||||
>
|
||||
<option value="">Select a blueprint</option>
|
||||
{blueprints.map((name) => (
|
||||
<option key={name} value={name}>
|
||||
{name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
{form.submitted && form.blueprint.length === 0 && (
|
||||
<p className="mt-1 text-xs text-red-400">Blueprint is required</p>
|
||||
)}
|
||||
</label>
|
||||
|
||||
{/* Description from definition */}
|
||||
{(blueprintDef.description !== undefined || blueprintDef.desc !== undefined) && (
|
||||
<p className="mt-1.5 text-xs text-fg-muted">
|
||||
{String(blueprintDef.description ?? blueprintDef.desc)}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Parameters schema */}
|
||||
{(() => {
|
||||
const paramsDef =
|
||||
blueprintDef.parameters ??
|
||||
blueprintDef.params ??
|
||||
blueprintDef["parameters"] ??
|
||||
blueprintDef["params"];
|
||||
if (paramsDef === undefined || paramsDef === null || typeof paramsDef !== "object") {
|
||||
return null;
|
||||
}
|
||||
const entries = Object.entries(paramsDef as Record<string, unknown>);
|
||||
if (entries.length === 0) return null;
|
||||
return (
|
||||
<div className="mt-2">
|
||||
<p className="text-xs font-medium text-fg-muted">Parameters</p>
|
||||
<div className="mt-1 space-y-1">
|
||||
{entries.map(([name, schema]) => {
|
||||
const s = schema as Record<string, unknown> | null;
|
||||
const type = s?.type !== undefined ? String(s.type) : undefined;
|
||||
const defaultVal = s !== null && "default" in s ? s.default : undefined;
|
||||
const desc = s?.description !== undefined ? String(s.description) : undefined;
|
||||
return (
|
||||
<div
|
||||
key={name}
|
||||
className="flex flex-wrap items-baseline gap-x-2 text-xs"
|
||||
>
|
||||
<span className="font-mono font-medium text-fg">{name}</span>
|
||||
{type !== undefined && (
|
||||
<span className="rounded bg-surface-200 px-1 py-0.5 text-[10px] text-fg-subtle">
|
||||
{type}
|
||||
</span>
|
||||
)}
|
||||
{defaultVal !== undefined && (
|
||||
<span className="text-fg-subtle">
|
||||
default: <span className="font-mono">{JSON.stringify(defaultVal)}</span>
|
||||
</span>
|
||||
)}
|
||||
{desc !== undefined && <span className="text-fg-subtle">- {desc}</span>}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* Raw JSON toggle */}
|
||||
{blueprintDetail !== null && (
|
||||
<div className="rounded-lg border border-border bg-surface-50 p-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDefExpanded((p) => !p)}
|
||||
className="mt-2 flex items-center gap-1 text-[11px] text-fg-subtle hover:text-fg-muted"
|
||||
onClick={() => setForm({ ...form, definitionExpanded: !form.definitionExpanded })}
|
||||
className="flex w-full items-center gap-1.5 text-left text-xs font-medium text-fg-muted"
|
||||
>
|
||||
{defExpanded ? (
|
||||
<ChevronDown className="h-3 w-3" />
|
||||
) : (
|
||||
<ChevronRight className="h-3 w-3" />
|
||||
)}
|
||||
Raw definition
|
||||
{form.definitionExpanded ? <ChevronDown className="h-3.5 w-3.5" /> : <ChevronRight className="h-3.5 w-3.5" />}
|
||||
<Info className="h-3.5 w-3.5 text-brand-400" />
|
||||
Blueprint Details
|
||||
</button>
|
||||
{defExpanded && (
|
||||
<pre className="mt-1 max-h-40 overflow-auto rounded border border-border bg-surface-100 p-2 font-mono text-[11px] text-fg-subtle">
|
||||
{JSON.stringify(blueprintDef, null, 2)}
|
||||
{form.definitionExpanded && (
|
||||
<pre className="mt-2 max-h-48 overflow-auto rounded-md bg-surface-100 p-2 font-mono text-[10px] text-fg-muted">
|
||||
{encodeJsonUnknownString(blueprintDetail)}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<div className="mb-3 space-y-1.5">
|
||||
<label htmlFor="flow-description" className="block text-sm font-medium text-fg-muted">
|
||||
Description <span className="text-error">*</span>
|
||||
</label>
|
||||
<input
|
||||
id="flow-description"
|
||||
type="text"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="Human-readable description"
|
||||
className="w-full rounded-lg border border-border bg-surface-100 px-3 py-2 text-sm text-fg placeholder:text-fg-subtle focus:border-brand-500 focus:outline-none focus:ring-1 focus:ring-brand-500"
|
||||
/>
|
||||
{submitted && description.trim().length === 0 && (
|
||||
<p className="mt-1 text-xs text-red-400">Description is required</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Parameters (JSON) */}
|
||||
<div className="space-y-1.5">
|
||||
<label htmlFor="flow-params" className="block text-sm font-medium text-fg-muted">
|
||||
Parameters (JSON)
|
||||
</label>
|
||||
<textarea
|
||||
id="flow-params"
|
||||
value={paramsJson}
|
||||
onChange={(e) => {
|
||||
setParamsJson(e.target.value);
|
||||
setParamsError(null);
|
||||
}}
|
||||
rows={4}
|
||||
className={cn(
|
||||
"w-full resize-none rounded-lg border bg-surface-100 px-3 py-2 font-mono text-xs text-fg placeholder:text-fg-subtle focus:outline-none focus:ring-1",
|
||||
paramsError !== null
|
||||
? "border-error focus:border-error focus:ring-error"
|
||||
: "border-border focus:border-brand-500 focus:ring-brand-500",
|
||||
<label className="block">
|
||||
<span className="mb-1 block text-sm font-medium text-fg-muted">Description</span>
|
||||
<input
|
||||
value={form.description}
|
||||
onChange={(event) => setForm({ ...form, description: event.target.value })}
|
||||
placeholder="What this flow does"
|
||||
className="w-full rounded-lg border border-border bg-surface-100 px-3 py-2 text-sm text-fg placeholder:text-fg-subtle focus:border-brand-500 focus:outline-none focus:ring-1 focus:ring-brand-500"
|
||||
/>
|
||||
{form.submitted && form.description.trim().length === 0 && (
|
||||
<p className="mt-1 text-xs text-red-400">Description is required</p>
|
||||
)}
|
||||
/>
|
||||
{paramsError !== null && (
|
||||
<p className="text-xs text-error">{paramsError}</p>
|
||||
)}
|
||||
</label>
|
||||
|
||||
<label className="block">
|
||||
<span className="mb-1 block text-sm font-medium text-fg-muted">Parameters JSON</span>
|
||||
<textarea
|
||||
value={form.paramsJson}
|
||||
onChange={(event) => setForm({ ...form, paramsJson: event.target.value, paramsError: null })}
|
||||
rows={6}
|
||||
className="w-full resize-none rounded-lg border border-border bg-surface-100 px-3 py-2 font-mono text-xs text-fg placeholder:text-fg-subtle focus:border-brand-500 focus:outline-none focus:ring-1 focus:ring-brand-500"
|
||||
/>
|
||||
{form.paramsError !== null && <p className="mt-1 text-xs text-red-400">{form.paramsError}</p>}
|
||||
</label>
|
||||
</div>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Stop flow confirm dialog
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function StopFlowDialog({
|
||||
open,
|
||||
flowId,
|
||||
onClose,
|
||||
onConfirm,
|
||||
}: {
|
||||
open: boolean;
|
||||
flowId: string;
|
||||
onClose: () => void;
|
||||
onConfirm: () => Promise<void>;
|
||||
}) {
|
||||
const [stopping, setStopping] = useState(false);
|
||||
|
||||
const handleStop = async () => {
|
||||
setStopping(true);
|
||||
try {
|
||||
await onConfirm();
|
||||
} finally {
|
||||
setStopping(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onClose={() => {
|
||||
if (!stopping) onClose();
|
||||
}}
|
||||
title="Stop Flow"
|
||||
footer={
|
||||
<>
|
||||
<button
|
||||
onClick={onClose}
|
||||
disabled={stopping}
|
||||
className="rounded-lg border border-border px-4 py-2 text-sm text-fg-muted hover:bg-surface-200 disabled:opacity-40"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleStop}
|
||||
disabled={stopping}
|
||||
className="flex items-center gap-2 rounded-lg bg-error px-4 py-2 text-sm font-medium text-white hover:opacity-90 disabled:opacity-40"
|
||||
>
|
||||
{stopping && <Loader2 className="h-3.5 w-3.5 animate-spin" />}
|
||||
Stop
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<AlertTriangle className="mt-0.5 h-5 w-5 shrink-0 text-error" />
|
||||
<p className="text-sm text-fg-muted">
|
||||
Are you sure you want to stop flow{" "}
|
||||
<span className="font-mono font-medium text-fg">{flowId}</span>?
|
||||
</p>
|
||||
</div>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Flow detail row (expandable)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function FlowRow({
|
||||
flow,
|
||||
onStop,
|
||||
}: {
|
||||
flow: FlowSummary;
|
||||
onStop: (id: string) => void;
|
||||
}) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
// Determine all the extra keys beyond id/description
|
||||
const detailKeys = Object.keys(flow).filter(
|
||||
(k) => k !== "id" && k !== "description",
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<tr
|
||||
className="cursor-pointer hover:bg-surface-100/50"
|
||||
onClick={() => setExpanded((p) => !p)}
|
||||
>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
{expanded ? (
|
||||
<ChevronDown className="h-3.5 w-3.5 shrink-0 text-fg-subtle" />
|
||||
) : (
|
||||
<ChevronRight className="h-3.5 w-3.5 shrink-0 text-fg-subtle" />
|
||||
)}
|
||||
<span className="font-mono text-sm text-fg">{flow.id}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-fg-muted">
|
||||
{(flow.description ?? "").length > 0 ? flow.description : "--"}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<Badge variant="success">Running</Badge>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onStop(flow.id);
|
||||
}}
|
||||
className="rounded p-1.5 text-fg-subtle hover:bg-error/10 hover:text-error"
|
||||
title="Stop flow"
|
||||
aria-label={`Stop flow ${flow.id}`}
|
||||
>
|
||||
<Square className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
{/* Detail row */}
|
||||
{expanded && detailKeys.length > 0 && (
|
||||
<tr>
|
||||
<td colSpan={4} className="bg-surface-50 px-8 py-3">
|
||||
<div className="grid grid-cols-2 gap-x-6 gap-y-2 text-xs">
|
||||
{detailKeys.map((key) => (
|
||||
<div key={key}>
|
||||
<span className="font-medium text-fg-muted">{key}: </span>
|
||||
<span className="text-fg-subtle">
|
||||
{typeof flow[key] === "object"
|
||||
? JSON.stringify(flow[key])
|
||||
: String(flow[key] ?? "")}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Flows page
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export default function FlowsPage() {
|
||||
const { flows, loading, error, getFlows, startFlow, stopFlow } = useFlows();
|
||||
const notify = useNotification();
|
||||
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [stopTarget, setStopTarget] = useState<string | null>(null);
|
||||
|
||||
// Auto-refresh every 10 seconds
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
getFlows();
|
||||
}, 10_000);
|
||||
return () => clearInterval(interval);
|
||||
}, [getFlows]);
|
||||
|
||||
// Also refresh on window focus
|
||||
useEffect(() => {
|
||||
const handler = () => getFlows();
|
||||
window.addEventListener("focus", handler);
|
||||
return () => window.removeEventListener("focus", handler);
|
||||
}, [getFlows]);
|
||||
|
||||
const handleStart = async (
|
||||
id: string,
|
||||
blueprint: string,
|
||||
description: string,
|
||||
params: Record<string, unknown>,
|
||||
) => {
|
||||
try {
|
||||
await startFlow(id, blueprint, description, params);
|
||||
notify.success("Flow started", `Flow "${id}" has been started.`);
|
||||
} catch (err) {
|
||||
notify.error(
|
||||
"Failed to start flow",
|
||||
err instanceof Error ? err.message : String(err),
|
||||
);
|
||||
throw err; // re-throw so dialog stays open
|
||||
}
|
||||
};
|
||||
|
||||
const handleStop = async () => {
|
||||
if (stopTarget === null || stopTarget.length === 0) return;
|
||||
try {
|
||||
await stopFlow(stopTarget);
|
||||
notify.success("Flow stopped", `Flow "${stopTarget}" has been stopped.`);
|
||||
} catch (err) {
|
||||
notify.error(
|
||||
"Failed to stop flow",
|
||||
err instanceof Error ? err.message : String(err),
|
||||
);
|
||||
}
|
||||
setStopTarget(null);
|
||||
};
|
||||
const flowsResult = useAtomValue(flowsAtom);
|
||||
const refreshFlows = useAtomRefresh(flowsAtom);
|
||||
const [expanded, setExpanded] = useAtom(flowExpandedAtom);
|
||||
const setStartOpen = useAtomSet(flowsStartDialogOpenAtom);
|
||||
const stopFlow = useAtomSet(stopFlowAtom);
|
||||
const actionInProgress = useAtomValue(activeActionAtom);
|
||||
const flows = resultData(flowsResult, []);
|
||||
const loading = resultLoading(flowsResult, flows);
|
||||
const error = resultError(flowsResult);
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
{/* Header */}
|
||||
<div className="mb-6 flex flex-wrap items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-3">
|
||||
<Workflow className="h-6 w-6 text-brand-400" />
|
||||
<h1 className="text-2xl font-bold text-fg">Flows</h1>
|
||||
<span className="ml-2 rounded bg-surface-200 px-2 py-0.5 text-xs text-fg-muted">
|
||||
{flows.length} active
|
||||
</span>
|
||||
{!loading && <Badge>{flows.length} flow{flows.length !== 1 ? "s" : ""}</Badge>}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => getFlows()}
|
||||
onClick={refreshFlows}
|
||||
disabled={loading}
|
||||
className="flex items-center gap-1.5 rounded-lg border border-border px-3 py-2 text-sm text-fg-muted transition-colors hover:bg-surface-200 disabled:opacity-40"
|
||||
>
|
||||
|
|
@ -587,16 +209,15 @@ export default function FlowsPage() {
|
|||
Refresh
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setCreateOpen(true)}
|
||||
className="flex items-center gap-2 rounded-lg bg-brand-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-brand-500"
|
||||
onClick={() => setStartOpen(true)}
|
||||
className="flex items-center gap-1.5 rounded-lg bg-brand-600 px-3 py-2 text-sm font-medium text-white hover:bg-brand-500"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
Start Flow
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
{loading && flows.length === 0 && (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="mr-2 h-5 w-5 animate-spin text-fg-subtle" />
|
||||
|
|
@ -605,7 +226,7 @@ export default function FlowsPage() {
|
|||
)}
|
||||
|
||||
{error !== null && (
|
||||
<p role="alert" className="mb-4 rounded-lg bg-error/10 px-4 py-2 text-sm text-error">
|
||||
<p className="mb-4 rounded-lg bg-error/10 px-4 py-2 text-sm text-error">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
|
@ -613,50 +234,51 @@ export default function FlowsPage() {
|
|||
{!loading && error === null && flows.length === 0 && (
|
||||
<div className="flex flex-1 flex-col items-center justify-center">
|
||||
<Workflow className="mb-3 h-10 w-10 text-fg-subtle opacity-30" />
|
||||
<p className="text-fg-subtle">No flows configured.</p>
|
||||
<p className="mt-1 text-xs text-fg-subtle">
|
||||
Click "Start Flow" to create one.
|
||||
</p>
|
||||
<p className="text-fg-subtle">No flows are running.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{flows.length > 0 && (
|
||||
<div className="overflow-x-auto rounded-lg border border-border">
|
||||
<table className="w-full text-left text-sm">
|
||||
<thead className="border-b border-border bg-surface-100 text-fg-muted">
|
||||
<tr>
|
||||
<th className="px-4 py-3 font-medium">ID</th>
|
||||
<th className="px-4 py-3 font-medium">Description</th>
|
||||
<th className="px-4 py-3 font-medium">Status</th>
|
||||
<th className="px-4 py-3 font-medium text-right">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{flows.map((flow) => (
|
||||
<FlowRow
|
||||
key={flow.id}
|
||||
flow={flow}
|
||||
onStop={(id) => setStopTarget(id)}
|
||||
/>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<div className="space-y-3">
|
||||
{flows.map((flow) => {
|
||||
const isExpanded = expanded[flow.id] === true;
|
||||
return (
|
||||
<div key={flow.id} className="rounded-lg border border-border bg-surface-50">
|
||||
<div className="flex items-center justify-between gap-3 px-4 py-3">
|
||||
<button
|
||||
onClick={() => setExpanded({ ...expanded, [flow.id]: !isExpanded })}
|
||||
className="flex min-w-0 flex-1 items-center gap-2 text-left"
|
||||
>
|
||||
{isExpanded ? <ChevronDown className="h-4 w-4 text-fg-subtle" /> : <ChevronRight className="h-4 w-4 text-fg-subtle" />}
|
||||
<span className="truncate font-mono text-sm font-medium text-fg">{flow.id}</span>
|
||||
{flow.description !== undefined && (
|
||||
<span className="truncate text-xs text-fg-muted">{flow.description}</span>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => stopFlow(flow.id)}
|
||||
disabled={actionInProgress === flow.id}
|
||||
aria-label={`Stop flow ${flow.id}`}
|
||||
className="flex items-center gap-1.5 rounded px-2.5 py-1.5 text-xs font-medium text-error hover:bg-error/10 disabled:opacity-40"
|
||||
>
|
||||
{actionInProgress === flow.id ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Square className="h-3.5 w-3.5" />}
|
||||
Stop
|
||||
</button>
|
||||
</div>
|
||||
{isExpanded && (
|
||||
<div className="border-t border-border px-4 py-3">
|
||||
<pre className="max-h-96 overflow-auto rounded-md bg-surface-100 p-3 font-mono text-xs text-fg-muted">
|
||||
{encodeJsonUnknownString(flow)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Dialogs */}
|
||||
<StartFlowDialog
|
||||
open={createOpen}
|
||||
onClose={() => setCreateOpen(false)}
|
||||
onStart={handleStart}
|
||||
/>
|
||||
|
||||
<StopFlowDialog
|
||||
open={stopTarget !== null}
|
||||
flowId={stopTarget ?? ""}
|
||||
onClose={() => setStopTarget(null)}
|
||||
onConfirm={handleStop}
|
||||
/>
|
||||
<StartFlowDialog />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,59 +1,37 @@
|
|||
import {
|
||||
lazy,
|
||||
Suspense,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { lazy, Suspense } from "react";
|
||||
import { useAtom, useAtomRefresh, useAtomValue } from "@effect/atom-react";
|
||||
import {
|
||||
Rotate3d,
|
||||
Search,
|
||||
ZoomIn,
|
||||
ZoomOut,
|
||||
Maximize,
|
||||
Loader2,
|
||||
X,
|
||||
ArrowRight,
|
||||
ArrowLeft,
|
||||
Filter,
|
||||
RefreshCw,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useSocket } from "@/providers/socket-provider";
|
||||
import { useSessionStore } from "@/hooks/use-session-store";
|
||||
import { useSettings } from "@/providers/settings-provider";
|
||||
import { useProgressStore } from "@/hooks/use-progress-store";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import type { Triple, Term } from "@trustgraph/client";
|
||||
import {
|
||||
termValue,
|
||||
flowIdAtom,
|
||||
graphTriplesAtom,
|
||||
graphViewAtom,
|
||||
resultData,
|
||||
resultError,
|
||||
resultLoading,
|
||||
settingsAtom,
|
||||
} from "@/atoms/workbench";
|
||||
import type { Triple } from "@trustgraph/client";
|
||||
import {
|
||||
localName,
|
||||
hashColor,
|
||||
triplesToGraph,
|
||||
RDFS_LABEL,
|
||||
RDF_TYPE,
|
||||
termValue,
|
||||
type GraphNode,
|
||||
type GraphLink,
|
||||
} from "@/lib/graph-utils";
|
||||
import type { ForceGraphProps } from "react-force-graph-2d";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Lazy-load ForceGraph2D to keep bundle size down
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
import type {
|
||||
ForceGraphMethods,
|
||||
ForceGraphProps,
|
||||
} from "react-force-graph-2d";
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const ForceGraph2D = lazy(() => import("react-force-graph-2d")) as unknown as React.ComponentType<ForceGraphProps<any, any> & { ref?: React.Ref<any> }>;
|
||||
|
||||
// Graph helpers imported from @/lib/graph-utils
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Node detail panel
|
||||
// ---------------------------------------------------------------------------
|
||||
const ForceGraph2D = lazy(() => import("react-force-graph-2d")) as unknown as React.ComponentType<ForceGraphProps<GraphNode, GraphLink>>;
|
||||
|
||||
function NodeDetailPanel({
|
||||
nodeId,
|
||||
|
|
@ -68,672 +46,243 @@ function NodeDetailPanel({
|
|||
labelMap: Map<string, string>;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
// Find triples where this node is subject or object
|
||||
const related = useMemo(() => {
|
||||
const outbound: { predicate: string; object: string; objectLabel: string }[] = [];
|
||||
const inbound: { predicate: string; subject: string; subjectLabel: string }[] = [];
|
||||
const outbound: { predicate: string; object: string; objectLabel: string }[] = [];
|
||||
const inbound: { predicate: string; subject: string; subjectLabel: string }[] = [];
|
||||
|
||||
for (const t of triples) {
|
||||
const sVal = termValue(t.s);
|
||||
const pVal = termValue(t.p);
|
||||
const oVal = termValue(t.o);
|
||||
|
||||
if (pVal === RDFS_LABEL || pVal === RDF_TYPE) continue;
|
||||
|
||||
if (sVal === nodeId) {
|
||||
outbound.push({
|
||||
predicate: labelMap.get(pVal) ?? localName(pVal),
|
||||
object: oVal,
|
||||
objectLabel: labelMap.get(oVal) ?? localName(oVal),
|
||||
});
|
||||
}
|
||||
if (oVal === nodeId) {
|
||||
inbound.push({
|
||||
predicate: labelMap.get(pVal) ?? localName(pVal),
|
||||
subject: sVal,
|
||||
subjectLabel: labelMap.get(sVal) ?? localName(sVal),
|
||||
});
|
||||
}
|
||||
for (const triple of triples) {
|
||||
const subject = termValue(triple.s);
|
||||
const predicate = termValue(triple.p);
|
||||
const object = termValue(triple.o);
|
||||
if (predicate === RDFS_LABEL || predicate === RDF_TYPE) continue;
|
||||
if (subject === nodeId) {
|
||||
outbound.push({
|
||||
predicate: labelMap.get(predicate) ?? localName(predicate),
|
||||
object,
|
||||
objectLabel: labelMap.get(object) ?? localName(object),
|
||||
});
|
||||
}
|
||||
return { outbound, inbound };
|
||||
}, [nodeId, triples, labelMap]);
|
||||
if (object === nodeId) {
|
||||
inbound.push({
|
||||
predicate: labelMap.get(predicate) ?? localName(predicate),
|
||||
subject,
|
||||
subjectLabel: labelMap.get(subject) ?? localName(subject),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-80 shrink-0 flex-col border-l border-border bg-surface-50">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between border-b border-border px-4 py-3">
|
||||
<h3 className="truncate text-sm font-semibold text-fg">{label}</h3>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="rounded p-1 text-fg-subtle hover:bg-surface-200 hover:text-fg"
|
||||
aria-label="Close detail panel"
|
||||
>
|
||||
<aside className="absolute right-4 top-4 z-20 max-h-[calc(100%-2rem)] w-96 overflow-y-auto rounded-lg border border-border bg-surface-50 shadow-xl">
|
||||
<div className="flex items-start justify-between gap-3 border-b border-border px-4 py-3">
|
||||
<div className="min-w-0">
|
||||
<h2 className="truncate text-sm font-semibold text-fg">{label}</h2>
|
||||
<p className="break-all font-mono text-[10px] text-fg-subtle">{nodeId}</p>
|
||||
</div>
|
||||
<button onClick={onClose} aria-label="Close node details" className="rounded p-1 text-fg-subtle hover:bg-surface-200 hover:text-fg">
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-4">
|
||||
<p className="mb-3 truncate font-mono text-[10px] text-fg-subtle">
|
||||
{nodeId}
|
||||
</p>
|
||||
|
||||
{/* Outbound relationships */}
|
||||
{related.outbound.length > 0 && (
|
||||
<div className="mb-4">
|
||||
<h4 className="mb-2 flex items-center gap-1.5 text-xs font-medium text-fg-muted">
|
||||
<ArrowRight className="h-3 w-3" />
|
||||
Outbound ({related.outbound.length})
|
||||
</h4>
|
||||
<div className="space-y-1">
|
||||
{related.outbound.map((r, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="flex items-center gap-1.5 rounded bg-surface-100 px-2 py-1.5 text-xs"
|
||||
>
|
||||
<Badge variant="default">{r.predicate}</Badge>
|
||||
<span className="truncate text-fg-muted">{r.objectLabel}</span>
|
||||
<div className="space-y-4 p-4">
|
||||
{outbound.length > 0 && (
|
||||
<section>
|
||||
<h3 className="mb-2 text-xs font-medium uppercase tracking-wider text-fg-subtle">Outgoing</h3>
|
||||
<div className="space-y-2">
|
||||
{outbound.map((edge, index) => (
|
||||
<div key={`${edge.object}-${index}`} className="rounded-md bg-surface-100 p-2 text-xs">
|
||||
<p className="text-fg-muted">{edge.predicate}</p>
|
||||
<p className="mt-0.5 break-all font-mono text-fg">{edge.objectLabel}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Inbound relationships */}
|
||||
{related.inbound.length > 0 && (
|
||||
<div>
|
||||
<h4 className="mb-2 flex items-center gap-1.5 text-xs font-medium text-fg-muted">
|
||||
<ArrowLeft className="h-3 w-3" />
|
||||
Inbound ({related.inbound.length})
|
||||
</h4>
|
||||
<div className="space-y-1">
|
||||
{related.inbound.map((r, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="flex items-center gap-1.5 rounded bg-surface-100 px-2 py-1.5 text-xs"
|
||||
>
|
||||
<span className="truncate text-fg-muted">{r.subjectLabel}</span>
|
||||
<Badge variant="default">{r.predicate}</Badge>
|
||||
{inbound.length > 0 && (
|
||||
<section>
|
||||
<h3 className="mb-2 text-xs font-medium uppercase tracking-wider text-fg-subtle">Incoming</h3>
|
||||
<div className="space-y-2">
|
||||
{inbound.map((edge, index) => (
|
||||
<div key={`${edge.subject}-${index}`} className="rounded-md bg-surface-100 p-2 text-xs">
|
||||
<p className="text-fg-muted">{edge.predicate}</p>
|
||||
<p className="mt-0.5 break-all font-mono text-fg">{edge.subjectLabel}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{related.outbound.length === 0 && related.inbound.length === 0 && (
|
||||
<p className="text-xs text-fg-subtle">No relationships found.</p>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Graph page
|
||||
// ---------------------------------------------------------------------------
|
||||
function paintNode(showLabels: boolean) {
|
||||
return (node: GraphNode, ctx: CanvasRenderingContext2D, globalScale: number) => {
|
||||
const radius = Math.max(3, Math.sqrt(node.degree + 1) * 2.2);
|
||||
const x = node.x ?? 0;
|
||||
const y = node.y ?? 0;
|
||||
ctx.beginPath();
|
||||
ctx.arc(x, y, radius, 0, 2 * Math.PI);
|
||||
ctx.fillStyle = node.color ?? "#5b80ff";
|
||||
ctx.fill();
|
||||
if (!showLabels || globalScale < 0.7) return;
|
||||
const fontSize = Math.max(10 / globalScale, 2);
|
||||
ctx.font = `${fontSize}px Inter, sans-serif`;
|
||||
ctx.textAlign = "center";
|
||||
ctx.textBaseline = "top";
|
||||
const light = document.documentElement.classList.contains("light");
|
||||
ctx.fillStyle = light ? "rgba(24,24,27,0.85)" : "rgba(250,250,250,0.85)";
|
||||
ctx.fillText(node.label, x, y + radius + 1);
|
||||
};
|
||||
}
|
||||
|
||||
function paintLink(link: GraphLink, ctx: CanvasRenderingContext2D, globalScale: number) {
|
||||
if (globalScale < 1.5) return;
|
||||
const source = link.source as unknown as GraphNode;
|
||||
const target = link.target as unknown as GraphNode;
|
||||
if (source.x === undefined || source.y === undefined || target.x === undefined || target.y === undefined) return;
|
||||
const midX = (source.x + target.x) / 2;
|
||||
const midY = (source.y + target.y) / 2;
|
||||
const fontSize = Math.max(8 / globalScale, 2);
|
||||
ctx.font = `${fontSize}px Inter, sans-serif`;
|
||||
ctx.textAlign = "center";
|
||||
ctx.textBaseline = "middle";
|
||||
ctx.fillStyle = "rgba(161,161,170,0.65)";
|
||||
ctx.fillText(link.label, midX, midY);
|
||||
}
|
||||
|
||||
export default function GraphPage() {
|
||||
const socket = useSocket();
|
||||
const flowId = useSessionStore((s) => s.flowId);
|
||||
const collection = useSettings((s) => s.settings.collection);
|
||||
const addActivity = useProgressStore((s) => s.addActivity);
|
||||
const removeActivity = useProgressStore((s) => s.removeActivity);
|
||||
|
||||
const [triples, setTriples] = useState<Triple[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [selectedNode, setSelectedNode] = useState<string | null>(null);
|
||||
|
||||
// Query filters
|
||||
const [showFilters, setShowFilters] = useState(false);
|
||||
const [subjectFilter, setSubjectFilter] = useState("");
|
||||
const [predicateFilter, setPredicateFilter] = useState("");
|
||||
const [objectFilter, setObjectFilter] = useState("");
|
||||
const [tripleLimit, setTripleLimit] = useState(2000);
|
||||
const [showLegend, setShowLegend] = useState(false);
|
||||
const hasActiveFilters =
|
||||
subjectFilter.length > 0 ||
|
||||
predicateFilter.length > 0 ||
|
||||
objectFilter.length > 0;
|
||||
|
||||
const fgRef = useRef<ForceGraphMethods<GraphNode, GraphLink> | undefined>(
|
||||
undefined,
|
||||
);
|
||||
const [containerSize, setContainerSize] = useState<{
|
||||
width: number;
|
||||
height: number;
|
||||
} | null>(null);
|
||||
const roRef = useRef<ResizeObserver | null>(null);
|
||||
|
||||
// Auto-fit tracking — declared early so fetchTriples can reset it
|
||||
const hasAutoFit = useRef(false);
|
||||
|
||||
// Ref callback — attaches ResizeObserver when the container mounts
|
||||
const containerRef = useCallback((el: HTMLDivElement | null) => {
|
||||
// Disconnect previous observer
|
||||
if (roRef.current !== null) {
|
||||
roRef.current.disconnect();
|
||||
roRef.current = null;
|
||||
}
|
||||
if (el === null) return;
|
||||
const ro = new ResizeObserver((entries) => {
|
||||
const entry = entries[0];
|
||||
if (entry !== undefined) {
|
||||
const { width, height } = entry.contentRect;
|
||||
setContainerSize({ width: Math.floor(width), height: Math.floor(height) });
|
||||
}
|
||||
});
|
||||
ro.observe(el);
|
||||
roRef.current = ro;
|
||||
}, []);
|
||||
|
||||
// Fetch triples with optional filters
|
||||
const fetchTriples = useCallback(async () => {
|
||||
const act = "Load graph";
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
addActivity(act);
|
||||
hasAutoFit.current = false;
|
||||
|
||||
const flow = socket.flow(flowId);
|
||||
const s: Term | undefined = subjectFilter.length > 0 ? { t: "i", i: subjectFilter } : undefined;
|
||||
const p: Term | undefined = predicateFilter.length > 0 ? { t: "i", i: predicateFilter } : undefined;
|
||||
const o: Term | undefined = objectFilter.length > 0 ? { t: "i", i: objectFilter } : undefined;
|
||||
|
||||
const result = await flow.triplesQuery(
|
||||
s,
|
||||
p,
|
||||
o,
|
||||
tripleLimit,
|
||||
collection,
|
||||
);
|
||||
setTriples(result);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
removeActivity(act);
|
||||
}
|
||||
}, [socket, flowId, collection, subjectFilter, predicateFilter, objectFilter, tripleLimit, addActivity, removeActivity]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchTriples();
|
||||
}, [fetchTriples]);
|
||||
|
||||
// Build graph
|
||||
const { data: graphData, labelMap, typeMap } = useMemo(
|
||||
() => triplesToGraph(Array.isArray(triples) ? triples : []),
|
||||
[triples],
|
||||
);
|
||||
|
||||
// Unique types for legend
|
||||
const uniqueTypes = useMemo(() => {
|
||||
const seen = new Map<string, string>();
|
||||
for (const [, typeUri] of typeMap) {
|
||||
const name = localName(typeUri);
|
||||
if (!seen.has(name)) {
|
||||
seen.set(name, typeUri);
|
||||
}
|
||||
}
|
||||
return Array.from(seen.entries());
|
||||
}, [typeMap]);
|
||||
|
||||
// Search filter -- highlight matching nodes
|
||||
const searchLower = searchTerm.toLowerCase();
|
||||
const matchingIds = useMemo(() => {
|
||||
if (searchLower.length === 0) return new Set<string>();
|
||||
return new Set(
|
||||
graphData.nodes
|
||||
.filter(
|
||||
(n) =>
|
||||
n.label.toLowerCase().includes(searchLower) ||
|
||||
n.id.toLowerCase().includes(searchLower),
|
||||
)
|
||||
.map((n) => n.id),
|
||||
);
|
||||
}, [graphData.nodes, searchLower]);
|
||||
|
||||
const selectedLabel = selectedNode !== null
|
||||
? labelMap.get(selectedNode) ?? localName(selectedNode)
|
||||
: "";
|
||||
|
||||
// Auto-fit graph to view once data loads
|
||||
useEffect(() => {
|
||||
if (
|
||||
graphData.nodes.length > 0 &&
|
||||
fgRef.current !== undefined &&
|
||||
hasAutoFit.current === false
|
||||
) {
|
||||
hasAutoFit.current = true;
|
||||
// Wait for force simulation to settle briefly before fitting
|
||||
const timer = setTimeout(() => fgRef.current?.zoomToFit(400, 40), 500);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [graphData.nodes.length]);
|
||||
|
||||
// Zoom helpers
|
||||
const zoomIn = () => fgRef.current?.zoom(2, 300);
|
||||
const zoomOut = () => fgRef.current?.zoom(0.5, 300);
|
||||
const zoomFit = () =>
|
||||
fgRef.current?.zoomToFit(400, 40);
|
||||
|
||||
// Node paint callback — with glow effect
|
||||
const paintNode = useCallback(
|
||||
(node: GraphNode, ctx: CanvasRenderingContext2D, globalScale: number) => {
|
||||
const isSelected = node.id === selectedNode;
|
||||
const isMatch = matchingIds.size > 0 && matchingIds.has(node.id);
|
||||
const dim = matchingIds.size > 0 && !isMatch && !isSelected;
|
||||
|
||||
const radius = Math.max(3, Math.sqrt(node.degree + 1) * 2.5);
|
||||
const x = node.x ?? 0;
|
||||
const y = node.y ?? 0;
|
||||
|
||||
const baseColor = dim
|
||||
? "rgba(100,100,100,0.3)"
|
||||
: isSelected
|
||||
? "#fbbf24"
|
||||
: isMatch
|
||||
? "#22c55e"
|
||||
: node.color ?? "#5b80ff";
|
||||
|
||||
// Outer glow (only when not dimmed)
|
||||
if (!dim) {
|
||||
ctx.save();
|
||||
ctx.shadowColor = baseColor;
|
||||
ctx.shadowBlur = isSelected ? 16 : 8;
|
||||
ctx.beginPath();
|
||||
ctx.arc(x, y, radius, 0, 2 * Math.PI);
|
||||
ctx.fillStyle = baseColor;
|
||||
ctx.fill();
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
// Node circle (crisp, on top of glow)
|
||||
ctx.beginPath();
|
||||
ctx.arc(x, y, radius, 0, 2 * Math.PI);
|
||||
ctx.fillStyle = baseColor;
|
||||
ctx.fill();
|
||||
|
||||
// Inner highlight (subtle white dot for depth)
|
||||
if (!dim && radius > 3) {
|
||||
ctx.beginPath();
|
||||
ctx.arc(x - radius * 0.25, y - radius * 0.25, radius * 0.3, 0, 2 * Math.PI);
|
||||
ctx.fillStyle = "rgba(255,255,255,0.2)";
|
||||
ctx.fill();
|
||||
}
|
||||
|
||||
if (isSelected || isMatch) {
|
||||
ctx.strokeStyle = isSelected ? "#fbbf24" : "#22c55e";
|
||||
ctx.lineWidth = 1.5 / globalScale;
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
// Label
|
||||
const fontSize = Math.max(10 / globalScale, 2);
|
||||
ctx.font = `600 ${fontSize}px Inter, sans-serif`;
|
||||
ctx.textAlign = "center";
|
||||
ctx.textBaseline = "top";
|
||||
const isLight = document.documentElement.classList.contains("light");
|
||||
ctx.fillStyle = dim
|
||||
? "rgba(100,100,100,0.3)"
|
||||
: isLight
|
||||
? "rgba(24,24,27,0.9)"
|
||||
: "rgba(250,250,250,0.9)";
|
||||
ctx.fillText(node.label, x, y + radius + 2);
|
||||
},
|
||||
[selectedNode, matchingIds],
|
||||
);
|
||||
|
||||
// Link label painting
|
||||
const paintLink = useCallback(
|
||||
(link: GraphLink, ctx: CanvasRenderingContext2D, globalScale: number) => {
|
||||
if (globalScale < 1.5) return; // only show labels when zoomed in enough
|
||||
|
||||
const src = link.source as unknown as GraphNode;
|
||||
const tgt = link.target as unknown as GraphNode;
|
||||
if (
|
||||
src.x === undefined ||
|
||||
src.y === undefined ||
|
||||
tgt.x === undefined ||
|
||||
tgt.y === undefined
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const midX = ((src.x ?? 0) + (tgt.x ?? 0)) / 2;
|
||||
const midY = ((src.y ?? 0) + (tgt.y ?? 0)) / 2;
|
||||
|
||||
const fontSize = Math.max(8 / globalScale, 1.5);
|
||||
ctx.font = `${fontSize}px Inter, sans-serif`;
|
||||
ctx.textAlign = "center";
|
||||
ctx.textBaseline = "middle";
|
||||
ctx.fillStyle = "rgba(161,161,170,0.7)";
|
||||
ctx.fillText(link.label, midX, midY);
|
||||
},
|
||||
[],
|
||||
);
|
||||
const flowId = useAtomValue(flowIdAtom);
|
||||
const collection = useAtomValue(settingsAtom).collection;
|
||||
const [view, setView] = useAtom(graphViewAtom);
|
||||
const triplesResult = useAtomValue(graphTriplesAtom({ flowId, collection, limit: view.nodeLimit }));
|
||||
const refresh = useAtomRefresh(graphTriplesAtom({ flowId, collection, limit: view.nodeLimit }));
|
||||
const triples = resultData(triplesResult, []);
|
||||
const loading = resultLoading(triplesResult, triples);
|
||||
const error = resultError(triplesResult);
|
||||
const { data, labelMap, typeMap } = triplesToGraph(triples);
|
||||
const search = view.searchTerm.trim().toLowerCase();
|
||||
const graphData = search.length === 0
|
||||
? data
|
||||
: (() => {
|
||||
const nodes = data.nodes.filter((node) => node.label.toLowerCase().includes(search) || node.id.toLowerCase().includes(search));
|
||||
const nodeIds = new Set(nodes.map((node) => node.id));
|
||||
return {
|
||||
nodes,
|
||||
links: data.links.filter((link) => {
|
||||
const source = typeof link.source === "string" ? link.source : (link.source as GraphNode).id;
|
||||
const target = typeof link.target === "string" ? link.target : (link.target as GraphNode).id;
|
||||
return nodeIds.has(source) && nodeIds.has(target);
|
||||
}),
|
||||
};
|
||||
})();
|
||||
const selectedNode = view.selectedNodeId !== null
|
||||
? data.nodes.find((node) => node.id === view.selectedNodeId)
|
||||
: undefined;
|
||||
const uniqueTypes = Array.from(new Set(Array.from(typeMap.values()).map(localName))).sort();
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
{/* Header */}
|
||||
<div className="mb-4 flex flex-wrap items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-3">
|
||||
<Rotate3d className="h-6 w-6 text-brand-400" />
|
||||
<h1 className="text-2xl font-bold text-fg">Graph</h1>
|
||||
<span className="ml-2 rounded bg-surface-200 px-2 py-0.5 text-xs text-fg-muted">
|
||||
{graphData.nodes.length} nodes, {graphData.links.length} edges
|
||||
</span>
|
||||
<Badge>{graphData.nodes.length} nodes</Badge>
|
||||
<Badge>{graphData.links.length} edges</Badge>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{/* Search */}
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-fg-subtle" />
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-2 rounded-lg border border-border bg-surface-100 px-3 py-2">
|
||||
<Search className="h-4 w-4 text-fg-subtle" />
|
||||
<input
|
||||
id="graph-search"
|
||||
type="text"
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
placeholder="Search nodes..."
|
||||
aria-label="Search nodes"
|
||||
className="w-48 rounded-lg border border-border bg-surface-100 py-1.5 pl-8 pr-3 text-xs text-fg placeholder:text-fg-subtle focus:border-brand-500 focus:outline-none focus:ring-1 focus:ring-brand-500"
|
||||
value={view.searchTerm}
|
||||
onChange={(event) => setView({ ...view, searchTerm: event.target.value })}
|
||||
placeholder="Search graph..."
|
||||
className="w-48 bg-transparent text-sm text-fg placeholder:text-fg-subtle focus:outline-none"
|
||||
/>
|
||||
{searchTerm.length > 0 && (
|
||||
<button
|
||||
onClick={() => setSearchTerm("")}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 text-fg-subtle hover:text-fg"
|
||||
aria-label="Clear search"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Zoom controls */}
|
||||
<div className="flex rounded-lg border border-border bg-surface-100">
|
||||
<button
|
||||
onClick={zoomIn}
|
||||
className="px-2 py-1.5 text-fg-muted hover:text-fg"
|
||||
title="Zoom in"
|
||||
aria-label="Zoom in"
|
||||
>
|
||||
<ZoomIn className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={zoomOut}
|
||||
className="border-l border-r border-border px-2 py-1.5 text-fg-muted hover:text-fg"
|
||||
title="Zoom out"
|
||||
aria-label="Zoom out"
|
||||
>
|
||||
<ZoomOut className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={zoomFit}
|
||||
className="px-2 py-1.5 text-fg-muted hover:text-fg"
|
||||
title="Fit to view"
|
||||
aria-label="Fit to view"
|
||||
>
|
||||
<Maximize className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Filter toggle */}
|
||||
<button
|
||||
onClick={() => setShowFilters((p) => !p)}
|
||||
className={cn(
|
||||
"flex items-center gap-1.5 rounded-lg border px-3 py-1.5 text-xs transition-colors",
|
||||
showFilters || hasActiveFilters
|
||||
? "border-brand-500/50 bg-brand-600/10 text-brand-400"
|
||||
: "border-border text-fg-muted hover:bg-surface-200",
|
||||
)}
|
||||
title="Query filters"
|
||||
aria-label="Toggle query filters"
|
||||
aria-expanded={showFilters}
|
||||
<select
|
||||
value={view.nodeLimit}
|
||||
onChange={(event) => setView({ ...view, nodeLimit: Number(event.target.value) })}
|
||||
aria-label="Node limit"
|
||||
className="rounded-lg border border-border bg-surface-100 px-3 py-2 text-sm text-fg focus:border-brand-500 focus:outline-none"
|
||||
>
|
||||
<Filter className="h-3.5 w-3.5" />
|
||||
Filters
|
||||
{hasActiveFilters && !showFilters && (
|
||||
<span className="ml-0.5 h-1.5 w-1.5 rounded-full bg-brand-400" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Legend toggle */}
|
||||
{uniqueTypes.length > 0 && (
|
||||
<button
|
||||
onClick={() => setShowLegend((p) => !p)}
|
||||
className={cn(
|
||||
"flex items-center gap-1.5 rounded-lg border px-3 py-1.5 text-xs transition-colors",
|
||||
showLegend
|
||||
? "border-brand-500/50 bg-brand-600/10 text-brand-400"
|
||||
: "border-border text-fg-muted hover:bg-surface-200",
|
||||
)}
|
||||
title="Type legend"
|
||||
aria-label="Toggle type legend"
|
||||
aria-expanded={showLegend}
|
||||
>
|
||||
Legend
|
||||
</button>
|
||||
)}
|
||||
|
||||
{[100, 250, 500, 1000].map((limit) => <option key={limit} value={limit}>{limit}</option>)}
|
||||
</select>
|
||||
<button
|
||||
onClick={fetchTriples}
|
||||
onClick={refresh}
|
||||
disabled={loading}
|
||||
className="flex items-center gap-1.5 rounded-lg border border-border px-3 py-1.5 text-xs text-fg-muted hover:bg-surface-200 disabled:opacity-40"
|
||||
aria-label="Refresh graph"
|
||||
className="rounded-lg border border-border px-3 py-2 text-fg-muted hover:bg-surface-200 disabled:opacity-40"
|
||||
>
|
||||
{loading ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<Rotate3d className="h-3.5 w-3.5" />
|
||||
)}
|
||||
Reload
|
||||
<RefreshCwIcon loading={loading} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filter panel */}
|
||||
{showFilters && (
|
||||
<div className="mb-4 rounded-lg border border-border bg-surface-50 p-4">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<h3 className="flex items-center gap-2 text-xs font-medium text-fg-muted">
|
||||
<Filter className="h-3 w-3" />
|
||||
Query Filters
|
||||
</h3>
|
||||
{hasActiveFilters && (
|
||||
<button
|
||||
onClick={() => {
|
||||
setSubjectFilter("");
|
||||
setPredicateFilter("");
|
||||
setObjectFilter("");
|
||||
}}
|
||||
className="text-xs text-brand-400 hover:text-brand-300"
|
||||
>
|
||||
Clear all
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div className="space-y-1">
|
||||
<label htmlFor="filter-subject" className="block text-[10px] font-medium uppercase tracking-wider text-fg-subtle">
|
||||
Subject
|
||||
</label>
|
||||
<input
|
||||
id="filter-subject"
|
||||
type="text"
|
||||
value={subjectFilter}
|
||||
onChange={(e) => setSubjectFilter(e.target.value)}
|
||||
placeholder="URI filter..."
|
||||
className="w-full rounded-lg border border-border bg-surface-100 px-3 py-1.5 text-xs text-fg placeholder:text-fg-subtle focus:border-brand-500 focus:outline-none focus:ring-1 focus:ring-brand-500"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label htmlFor="filter-predicate" className="block text-[10px] font-medium uppercase tracking-wider text-fg-subtle">
|
||||
Predicate
|
||||
</label>
|
||||
<input
|
||||
id="filter-predicate"
|
||||
type="text"
|
||||
value={predicateFilter}
|
||||
onChange={(e) => setPredicateFilter(e.target.value)}
|
||||
placeholder="URI filter..."
|
||||
className="w-full rounded-lg border border-border bg-surface-100 px-3 py-1.5 text-xs text-fg placeholder:text-fg-subtle focus:border-brand-500 focus:outline-none focus:ring-1 focus:ring-brand-500"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label htmlFor="filter-object" className="block text-[10px] font-medium uppercase tracking-wider text-fg-subtle">
|
||||
Object
|
||||
</label>
|
||||
<input
|
||||
id="filter-object"
|
||||
type="text"
|
||||
value={objectFilter}
|
||||
onChange={(e) => setObjectFilter(e.target.value)}
|
||||
placeholder="URI filter..."
|
||||
className="w-full rounded-lg border border-border bg-surface-100 px-3 py-1.5 text-xs text-fg placeholder:text-fg-subtle focus:border-brand-500 focus:outline-none focus:ring-1 focus:ring-brand-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 flex items-center gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<label htmlFor="filter-limit" className="text-[10px] font-medium uppercase tracking-wider text-fg-subtle">
|
||||
Limit
|
||||
</label>
|
||||
<input
|
||||
id="filter-limit"
|
||||
type="range"
|
||||
min={100}
|
||||
max={5000}
|
||||
step={100}
|
||||
value={tripleLimit}
|
||||
onChange={(e) => setTripleLimit(Number(e.target.value))}
|
||||
className="w-24 accent-brand-500"
|
||||
/>
|
||||
<span className="text-xs text-fg-muted">{tripleLimit}</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={fetchTriples}
|
||||
disabled={loading}
|
||||
className="ml-auto flex items-center gap-1.5 rounded-lg bg-brand-600 px-4 py-1.5 text-xs font-medium text-white transition-colors hover:bg-brand-500 disabled:opacity-40"
|
||||
>
|
||||
Apply
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Content */}
|
||||
{error !== null && (
|
||||
<p className="mb-4 rounded-lg bg-error/10 px-4 py-2 text-sm text-error">
|
||||
{error}
|
||||
</p>
|
||||
<p className="mb-4 rounded-lg bg-error/10 px-4 py-2 text-sm text-error">{error}</p>
|
||||
)}
|
||||
|
||||
{loading && triples.length === 0 && (
|
||||
<div className="flex flex-1 items-center justify-center">
|
||||
<Loader2 className="mr-2 h-5 w-5 animate-spin text-fg-subtle" />
|
||||
<span className="text-fg-subtle">Loading graph data...</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="mb-3 flex flex-wrap items-center gap-2">
|
||||
<button
|
||||
onClick={() => setView({ ...view, showLabels: !view.showLabels })}
|
||||
className={cn("flex items-center gap-1.5 rounded-md border border-border px-2.5 py-1.5 text-xs", view.showLabels ? "bg-brand-600/10 text-brand-400" : "text-fg-muted")}
|
||||
>
|
||||
<Filter className="h-3.5 w-3.5" />
|
||||
Labels
|
||||
</button>
|
||||
{uniqueTypes.slice(0, 8).map((type) => <Badge key={type} variant="info">{type}</Badge>)}
|
||||
</div>
|
||||
|
||||
{!loading && graphData.nodes.length === 0 && (
|
||||
<div className="flex flex-1 items-center justify-center rounded-lg border border-dashed border-border">
|
||||
<div className="text-center">
|
||||
<Rotate3d className="mx-auto mb-3 h-10 w-10 text-fg-subtle opacity-30" />
|
||||
<p className="text-fg-subtle">No graph data in this collection.</p>
|
||||
<p className="mt-1 text-xs text-fg-subtle">
|
||||
Upload documents and process them to populate the knowledge graph.
|
||||
</p>
|
||||
<div className="relative min-h-0 flex-1 overflow-hidden rounded-lg border border-border bg-surface-50">
|
||||
{loading && triples.length === 0 && (
|
||||
<div className="absolute inset-0 z-10 flex items-center justify-center bg-surface-50">
|
||||
<Loader2 className="mr-2 h-5 w-5 animate-spin text-fg-subtle" />
|
||||
<span className="text-fg-subtle">Loading graph...</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
|
||||
{graphData.nodes.length > 0 && (
|
||||
<div className="relative flex flex-1 overflow-hidden rounded-lg border border-border">
|
||||
{/* Graph canvas */}
|
||||
<div ref={containerRef} className="relative min-w-0 flex-1 bg-surface-0">
|
||||
<Suspense fallback={<div className="flex h-full items-center justify-center"><Loader2 className="h-5 w-5 animate-spin text-fg-subtle" /></div>}>
|
||||
{!loading && graphData.nodes.length === 0 && (
|
||||
<div className="absolute inset-0 z-10 flex flex-col items-center justify-center">
|
||||
<Rotate3d className="mb-3 h-10 w-10 text-fg-subtle opacity-30" />
|
||||
<p className="text-fg-subtle">No graph triples available.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{graphData.nodes.length > 0 && (
|
||||
<Suspense fallback={<div className="flex h-full items-center justify-center text-fg-subtle">Loading graph renderer...</div>}>
|
||||
<ForceGraph2D
|
||||
ref={fgRef}
|
||||
graphData={graphData}
|
||||
nodeCanvasObject={paintNode}
|
||||
nodePointerAreaPaint={(node: GraphNode, color, ctx) => {
|
||||
const radius = Math.max(3, Math.sqrt(node.degree + 1) * 2.5);
|
||||
ctx.beginPath();
|
||||
ctx.arc(node.x ?? 0, node.y ?? 0, radius + 2, 0, 2 * Math.PI);
|
||||
ctx.fillStyle = color;
|
||||
ctx.fill();
|
||||
}}
|
||||
width={900}
|
||||
height={650}
|
||||
backgroundColor="rgba(0,0,0,0)"
|
||||
nodeCanvasObject={paintNode(view.showLabels)}
|
||||
linkCanvasObjectMode={() => "after"}
|
||||
linkCanvasObject={paintLink}
|
||||
linkColor={() => "rgba(91,128,255,0.18)"}
|
||||
linkWidth={1.5}
|
||||
linkDirectionalArrowLength={5}
|
||||
linkDirectionalArrowRelPos={0.85}
|
||||
linkDirectionalArrowColor={() => "rgba(91,128,255,0.5)"}
|
||||
linkDirectionalParticles={2}
|
||||
linkDirectionalParticleWidth={2}
|
||||
linkDirectionalParticleSpeed={0.004}
|
||||
linkDirectionalParticleColor={() => "rgba(91,128,255,0.6)"}
|
||||
linkCurvature={0.1}
|
||||
onNodeClick={(node: GraphNode) => {
|
||||
setSelectedNode((prev) =>
|
||||
prev === node.id ? null : node.id,
|
||||
);
|
||||
linkColor={() => "rgba(120,120,140,0.32)"}
|
||||
nodePointerAreaPaint={(node, color, ctx) => {
|
||||
ctx.fillStyle = color;
|
||||
ctx.beginPath();
|
||||
ctx.arc(node.x ?? 0, node.y ?? 0, Math.max(6, Math.sqrt(node.degree + 1) * 3), 0, 2 * Math.PI);
|
||||
ctx.fill();
|
||||
}}
|
||||
onBackgroundClick={() => setSelectedNode(null)}
|
||||
backgroundColor="transparent"
|
||||
cooldownTicks={100}
|
||||
warmupTicks={30}
|
||||
{...(containerSize !== null
|
||||
? { width: containerSize.width, height: containerSize.height }
|
||||
: {})}
|
||||
onNodeClick={(node) => setView({ ...view, selectedNodeId: node.id, selectedNodeLabel: node.label })}
|
||||
/>
|
||||
</Suspense>
|
||||
</Suspense>
|
||||
)}
|
||||
|
||||
{/* Search results badge overlay */}
|
||||
{searchTerm.length > 0 && matchingIds.size > 0 && (
|
||||
<div className="absolute bottom-3 left-3">
|
||||
<Badge variant="success">
|
||||
{matchingIds.size} match{matchingIds.size > 1 ? "es" : ""}
|
||||
</Badge>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Type legend overlay */}
|
||||
{showLegend && uniqueTypes.length > 0 && (
|
||||
<div className="absolute bottom-3 left-3 z-10 max-h-48 overflow-y-auto rounded-lg border border-border bg-surface-50/95 px-3 py-2 shadow-lg backdrop-blur-sm">
|
||||
<h4 className="mb-1.5 text-[10px] font-medium uppercase tracking-wider text-fg-subtle">
|
||||
Node Types
|
||||
</h4>
|
||||
<div className="space-y-1">
|
||||
{uniqueTypes.map(([name]) => (
|
||||
<div key={name} className="flex items-center gap-2 text-xs text-fg-muted">
|
||||
<span
|
||||
className="inline-block h-2.5 w-2.5 shrink-0 rounded-full"
|
||||
style={{ backgroundColor: hashColor(name) }}
|
||||
/>
|
||||
<span className="truncate">{name}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Detail panel -- positioned absolutely so it overlays the graph */}
|
||||
{selectedNode !== null && (
|
||||
<div className="absolute inset-y-0 right-0 z-10">
|
||||
<NodeDetailPanel
|
||||
nodeId={selectedNode}
|
||||
label={selectedLabel}
|
||||
triples={triples}
|
||||
labelMap={labelMap}
|
||||
onClose={() => setSelectedNode(null)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{selectedNode !== undefined && view.selectedNodeId !== null && (
|
||||
<NodeDetailPanel
|
||||
nodeId={view.selectedNodeId}
|
||||
label={view.selectedNodeLabel ?? selectedNode.label}
|
||||
triples={triples}
|
||||
labelMap={labelMap}
|
||||
onClose={() => setView({ ...view, selectedNodeId: null, selectedNodeLabel: null })}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RefreshCwIcon({ loading }: { loading: boolean }) {
|
||||
return loading ? <Loader2 className="h-4 w-4 animate-spin" /> : <RefreshCw className="h-4 w-4" />;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useAtom, useAtomRefresh, useAtomSet, useAtomValue } from "@effect/atom-react";
|
||||
import {
|
||||
BrainCircuit,
|
||||
Loader2,
|
||||
|
|
@ -8,30 +8,30 @@ import {
|
|||
AlertTriangle,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useSocket } from "@/providers/socket-provider";
|
||||
import { useConnectionState } from "@/providers/socket-provider";
|
||||
import { useNotification } from "@/providers/notification-provider";
|
||||
import { useSessionStore } from "@/hooks/use-session-store";
|
||||
import {
|
||||
activeActionAtom,
|
||||
deleteKgCoreAtom,
|
||||
kgCoresAtom,
|
||||
knowledgeDeleteTargetAtom,
|
||||
loadKgCoreAtom,
|
||||
resultData,
|
||||
resultError,
|
||||
resultLoading,
|
||||
} from "@/atoms/workbench";
|
||||
import { Dialog } from "@/components/ui/dialog";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Delete confirmation dialog
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function DeleteCoreDialog({
|
||||
open,
|
||||
coreId,
|
||||
onClose,
|
||||
onConfirm,
|
||||
}: {
|
||||
open: boolean;
|
||||
coreId: string;
|
||||
coreId: string | null;
|
||||
onClose: () => void;
|
||||
onConfirm: () => void;
|
||||
}) {
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
open={coreId !== null}
|
||||
onClose={onClose}
|
||||
title="Delete Knowledge Core"
|
||||
footer={
|
||||
|
|
@ -55,7 +55,7 @@ function DeleteCoreDialog({
|
|||
<AlertTriangle className="mt-0.5 h-5 w-5 shrink-0 text-error" />
|
||||
<p className="text-sm text-fg-muted">
|
||||
Are you sure you want to delete knowledge core{" "}
|
||||
<span className="font-mono font-medium text-fg">{coreId}</span>?
|
||||
<span className="font-mono font-medium text-fg">{coreId ?? ""}</span>?
|
||||
This action cannot be undone.
|
||||
</p>
|
||||
</div>
|
||||
|
|
@ -63,93 +63,20 @@ function DeleteCoreDialog({
|
|||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Knowledge Cores page
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export default function KnowledgeCoresPage() {
|
||||
const socket = useSocket();
|
||||
const connectionState = useConnectionState();
|
||||
const notify = useNotification();
|
||||
const flowId = useSessionStore((s) => s.flowId);
|
||||
const result = useAtomValue(kgCoresAtom);
|
||||
const refresh = useAtomRefresh(kgCoresAtom);
|
||||
const loadCore = useAtomSet(loadKgCoreAtom);
|
||||
const deleteCore = useAtomSet(deleteKgCoreAtom);
|
||||
const [deleteTarget, setDeleteTarget] = useAtom(knowledgeDeleteTargetAtom);
|
||||
const actionInProgress = useAtomValue(activeActionAtom);
|
||||
|
||||
const [cores, setCores] = useState<string[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [deleteTarget, setDeleteTarget] = useState<string | null>(null);
|
||||
const [actionInProgress, setActionInProgress] = useState<string | null>(null);
|
||||
|
||||
const loadCores = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const timeoutPromise = new Promise<never>((_, reject) =>
|
||||
setTimeout(() => reject(new Error("Request timed out")), 15000),
|
||||
);
|
||||
const ids = await Promise.race([
|
||||
socket.knowledge().getKnowledgeCores(),
|
||||
timeoutPromise,
|
||||
]);
|
||||
setCores(Array.isArray(ids) ? ids : []);
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
setError(msg);
|
||||
console.error("Failed to load knowledge cores:", err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [socket]);
|
||||
|
||||
// Auto-load when connected
|
||||
useEffect(() => {
|
||||
const connected =
|
||||
connectionState.status === "connected" ||
|
||||
connectionState.status === "authenticated" ||
|
||||
connectionState.status === "unauthenticated";
|
||||
if (connected) {
|
||||
loadCores();
|
||||
}
|
||||
}, [connectionState.status, loadCores]);
|
||||
|
||||
const handleLoad = useCallback(
|
||||
async (id: string) => {
|
||||
setActionInProgress(id);
|
||||
try {
|
||||
await socket.knowledge().loadKgCore(id, flowId);
|
||||
notify.success("Core loaded", `Knowledge core "${id}" has been loaded.`);
|
||||
} catch (err) {
|
||||
notify.error(
|
||||
"Failed to load core",
|
||||
err instanceof Error ? err.message : String(err),
|
||||
);
|
||||
} finally {
|
||||
setActionInProgress(null);
|
||||
}
|
||||
},
|
||||
[socket, flowId, notify],
|
||||
);
|
||||
|
||||
const handleDelete = useCallback(async () => {
|
||||
if (deleteTarget === null || deleteTarget.length === 0) return;
|
||||
setActionInProgress(deleteTarget);
|
||||
try {
|
||||
await socket.knowledge().deleteKgCore(deleteTarget);
|
||||
notify.success("Core deleted", `Knowledge core "${deleteTarget}" has been deleted.`);
|
||||
await loadCores();
|
||||
} catch (err) {
|
||||
notify.error(
|
||||
"Failed to delete core",
|
||||
err instanceof Error ? err.message : String(err),
|
||||
);
|
||||
} finally {
|
||||
setActionInProgress(null);
|
||||
setDeleteTarget(null);
|
||||
}
|
||||
}, [socket, deleteTarget, notify, loadCores]);
|
||||
const cores = resultData(result, []);
|
||||
const loading = resultLoading(result, cores);
|
||||
const error = resultError(result);
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
{/* Header */}
|
||||
<div className="mb-6 flex flex-wrap items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-3">
|
||||
<BrainCircuit className="h-6 w-6 text-brand-400" />
|
||||
|
|
@ -162,7 +89,7 @@ export default function KnowledgeCoresPage() {
|
|||
</div>
|
||||
|
||||
<button
|
||||
onClick={loadCores}
|
||||
onClick={refresh}
|
||||
disabled={loading}
|
||||
className="flex items-center gap-1.5 rounded-lg border border-border px-3 py-2 text-sm text-fg-muted transition-colors hover:bg-surface-200 disabled:opacity-40"
|
||||
>
|
||||
|
|
@ -171,7 +98,6 @@ export default function KnowledgeCoresPage() {
|
|||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
{loading && cores.length === 0 && (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="mr-2 h-5 w-5 animate-spin text-fg-subtle" />
|
||||
|
|
@ -179,7 +105,7 @@ export default function KnowledgeCoresPage() {
|
|||
</div>
|
||||
)}
|
||||
|
||||
{error !== null && error.length > 0 && (
|
||||
{error !== null && (
|
||||
<p className="mb-4 rounded-lg bg-error/10 px-4 py-2 text-sm text-error">
|
||||
{error}
|
||||
</p>
|
||||
|
|
@ -210,7 +136,7 @@ export default function KnowledgeCoresPage() {
|
|||
<td className="px-4 py-3 text-right">
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<button
|
||||
onClick={() => handleLoad(id)}
|
||||
onClick={() => loadCore(id)}
|
||||
disabled={actionInProgress === id}
|
||||
className="flex items-center gap-1.5 rounded px-2.5 py-1.5 text-xs font-medium text-brand-400 hover:bg-brand-600/10 disabled:opacity-40"
|
||||
title="Load core"
|
||||
|
|
@ -242,12 +168,13 @@ export default function KnowledgeCoresPage() {
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* Delete confirmation dialog */}
|
||||
<DeleteCoreDialog
|
||||
open={deleteTarget != null}
|
||||
coreId={deleteTarget ?? ""}
|
||||
coreId={deleteTarget}
|
||||
onClose={() => setDeleteTarget(null)}
|
||||
onConfirm={handleDelete}
|
||||
onConfirm={() => {
|
||||
if (deleteTarget !== null) deleteCore(deleteTarget);
|
||||
setDeleteTarget(null);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
|
|
@ -1,4 +1,4 @@
|
|||
import { useCallback, useState } from "react";
|
||||
import { useAtom, useAtomRefresh, useAtomValue } from "@effect/atom-react";
|
||||
import {
|
||||
MessageCircleCode,
|
||||
Loader2,
|
||||
|
|
@ -9,47 +9,40 @@ import {
|
|||
Terminal,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { usePrompts } from "@/hooks/use-prompts";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Prompts page
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type Tab = "templates" | "system";
|
||||
import {
|
||||
promptActiveTabAtom,
|
||||
promptDetailAtom,
|
||||
promptsAtom,
|
||||
resultData,
|
||||
resultError,
|
||||
resultLoading,
|
||||
selectedPromptIdAtom,
|
||||
systemPromptAtom,
|
||||
} from "@/atoms/workbench";
|
||||
|
||||
export default function PromptsPage() {
|
||||
const { prompts, systemPrompt, loading, error, loadPrompts, loadSystemPrompt, getPrompt } = usePrompts();
|
||||
const promptsResult = useAtomValue(promptsAtom);
|
||||
const systemPromptResult = useAtomValue(systemPromptAtom);
|
||||
const refreshPrompts = useAtomRefresh(promptsAtom);
|
||||
const refreshSystemPrompt = useAtomRefresh(systemPromptAtom);
|
||||
const [activeTab, setActiveTab] = useAtom(promptActiveTabAtom);
|
||||
const [selectedPromptId, setSelectedPromptId] = useAtom(selectedPromptIdAtom);
|
||||
const promptDetailResult = useAtomValue(promptDetailAtom(selectedPromptId ?? ""));
|
||||
|
||||
const [activeTab, setActiveTab] = useState<Tab>("templates");
|
||||
const [selectedPromptId, setSelectedPromptId] = useState<string | null>(null);
|
||||
const [promptDetail, setPromptDetail] = useState<{ system?: string; prompt?: string } | string | null>(null);
|
||||
const [loadingDetail, setLoadingDetail] = useState(false);
|
||||
const prompts = resultData(promptsResult, []);
|
||||
const systemPrompt = resultData(systemPromptResult, "");
|
||||
const loading = resultLoading(promptsResult, prompts) || resultLoading(systemPromptResult, systemPrompt);
|
||||
const error = resultError(promptsResult) ?? resultError(systemPromptResult);
|
||||
const promptDetail = resultData(promptDetailResult, null) as { system?: string; prompt?: string } | string | null;
|
||||
const loadingDetail = selectedPromptId !== null && resultLoading(promptDetailResult, promptDetail);
|
||||
|
||||
const handleSelectPrompt = useCallback(
|
||||
async (id: string) => {
|
||||
setSelectedPromptId(id);
|
||||
setLoadingDetail(true);
|
||||
try {
|
||||
const detail = await getPrompt(id);
|
||||
setPromptDetail(detail as typeof promptDetail);
|
||||
} catch (err) {
|
||||
console.error("Failed to load prompt detail:", err);
|
||||
setPromptDetail("Error loading prompt.");
|
||||
} finally {
|
||||
setLoadingDetail(false);
|
||||
}
|
||||
},
|
||||
[getPrompt],
|
||||
);
|
||||
|
||||
const handleRefresh = useCallback(() => {
|
||||
loadPrompts();
|
||||
loadSystemPrompt();
|
||||
}, [loadPrompts, loadSystemPrompt]);
|
||||
const refresh = () => {
|
||||
refreshPrompts();
|
||||
refreshSystemPrompt();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
{/* Header */}
|
||||
<div className="mb-6 flex flex-wrap items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-3">
|
||||
<MessageCircleCode className="h-6 w-6 text-brand-400" />
|
||||
|
|
@ -57,7 +50,7 @@ export default function PromptsPage() {
|
|||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleRefresh}
|
||||
onClick={refresh}
|
||||
disabled={loading}
|
||||
className="flex items-center gap-1.5 rounded-lg border border-border px-3 py-2 text-sm text-fg-muted transition-colors hover:bg-surface-200 disabled:opacity-40"
|
||||
>
|
||||
|
|
@ -66,7 +59,6 @@ export default function PromptsPage() {
|
|||
</button>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div role="tablist" aria-label="Prompt sections" className="mb-4 flex gap-1 rounded-lg bg-surface-100 p-1">
|
||||
<button
|
||||
id="tab-templates"
|
||||
|
|
@ -75,9 +67,7 @@ export default function PromptsPage() {
|
|||
onClick={() => setActiveTab("templates")}
|
||||
className={cn(
|
||||
"flex items-center gap-2 rounded-md px-4 py-2 text-sm font-medium transition-colors",
|
||||
activeTab === "templates"
|
||||
? "bg-surface-50 text-fg shadow-sm"
|
||||
: "text-fg-muted hover:text-fg",
|
||||
activeTab === "templates" ? "bg-surface-50 text-fg shadow-sm" : "text-fg-muted hover:text-fg",
|
||||
)}
|
||||
>
|
||||
<FileText className="h-3.5 w-3.5" />
|
||||
|
|
@ -90,9 +80,7 @@ export default function PromptsPage() {
|
|||
onClick={() => setActiveTab("system")}
|
||||
className={cn(
|
||||
"flex items-center gap-2 rounded-md px-4 py-2 text-sm font-medium transition-colors",
|
||||
activeTab === "system"
|
||||
? "bg-surface-50 text-fg shadow-sm"
|
||||
: "text-fg-muted hover:text-fg",
|
||||
activeTab === "system" ? "bg-surface-50 text-fg shadow-sm" : "text-fg-muted hover:text-fg",
|
||||
)}
|
||||
>
|
||||
<Terminal className="h-3.5 w-3.5" />
|
||||
|
|
@ -100,14 +88,12 @@ export default function PromptsPage() {
|
|||
</button>
|
||||
</div>
|
||||
|
||||
{/* Error display */}
|
||||
{error !== null && error.length > 0 && (
|
||||
{error !== null && (
|
||||
<p className="mb-4 rounded-lg bg-error/10 px-4 py-2 text-sm text-error">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Templates tab */}
|
||||
{activeTab === "templates" && (
|
||||
<div id="panel-templates" role="tabpanel" aria-labelledby="tab-templates" tabIndex={0} className="flex flex-1 flex-col gap-4 overflow-hidden">
|
||||
{loading && prompts.length === 0 && (
|
||||
|
|
@ -125,21 +111,20 @@ export default function PromptsPage() {
|
|||
)}
|
||||
|
||||
{prompts.length > 0 && (
|
||||
<div className="flex flex-1 gap-4 overflow-hidden">
|
||||
{/* Prompt list */}
|
||||
<div className="w-80 shrink-0 overflow-y-auto rounded-lg border border-border">
|
||||
<div className="flex flex-1 flex-col gap-4 overflow-hidden lg:flex-row">
|
||||
<div className="max-h-56 w-full shrink-0 overflow-y-auto rounded-lg border border-border lg:max-h-none lg:w-80">
|
||||
<div className="border-b border-border bg-surface-100 px-4 py-3">
|
||||
<h2 className="text-xs font-medium uppercase tracking-wider text-fg-muted">
|
||||
Templates ({prompts.length})
|
||||
</h2>
|
||||
</div>
|
||||
<div className="divide-y divide-border">
|
||||
{prompts.map((p) => {
|
||||
const id = p.id ?? (p as Record<string, unknown>).name ?? String(p);
|
||||
{prompts.map((prompt) => {
|
||||
const id = prompt.id ?? (prompt as Record<string, unknown>).name ?? String(prompt);
|
||||
return (
|
||||
<button
|
||||
key={String(id)}
|
||||
onClick={() => handleSelectPrompt(String(id))}
|
||||
onClick={() => setSelectedPromptId(String(id))}
|
||||
className={cn(
|
||||
"flex w-full items-center justify-between px-4 py-3 text-left text-sm transition-colors",
|
||||
selectedPromptId === String(id)
|
||||
|
|
@ -155,8 +140,7 @@ export default function PromptsPage() {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{/* Prompt detail */}
|
||||
<div className="flex flex-1 flex-col overflow-hidden rounded-lg border border-border">
|
||||
<div className="min-h-0 flex flex-1 flex-col overflow-hidden rounded-lg border border-border">
|
||||
{selectedPromptId !== null && selectedPromptId.length > 0 ? (
|
||||
<>
|
||||
<div className="flex items-center justify-between border-b border-border bg-surface-100 px-4 py-3">
|
||||
|
|
@ -164,10 +148,8 @@ export default function PromptsPage() {
|
|||
<span className="font-mono">{selectedPromptId}</span>
|
||||
</h2>
|
||||
<button
|
||||
onClick={() => {
|
||||
setSelectedPromptId(null);
|
||||
setPromptDetail("");
|
||||
}}
|
||||
onClick={() => setSelectedPromptId(null)}
|
||||
aria-label="Close prompt detail"
|
||||
className="rounded-md p-1 text-fg-subtle hover:bg-surface-200 hover:text-fg"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
|
|
@ -220,7 +202,6 @@ export default function PromptsPage() {
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* System Prompt tab */}
|
||||
{activeTab === "system" && (
|
||||
<div id="panel-system" role="tabpanel" aria-labelledby="tab-system" tabIndex={0} className="flex flex-1 flex-col overflow-hidden rounded-lg border border-border">
|
||||
<div className="border-b border-border bg-surface-100 px-4 py-3">
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useAtom, useAtomSet, useAtomValue } from "@effect/atom-react";
|
||||
import {
|
||||
Settings as SettingsIcon,
|
||||
Wifi,
|
||||
|
|
@ -9,42 +9,44 @@ import {
|
|||
Database,
|
||||
Workflow,
|
||||
Info,
|
||||
Loader2,
|
||||
Moon,
|
||||
Sun,
|
||||
Plus,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useSettings } from "@/providers/settings-provider";
|
||||
import { useSocket } from "@/providers/socket-provider";
|
||||
import { useConnectionState } from "@/providers/socket-provider";
|
||||
import { useFlows } from "@/hooks/use-flows";
|
||||
import { useSessionStore } from "@/hooks/use-session-store";
|
||||
import { useNotification } from "@/providers/notification-provider";
|
||||
import type * as React from "react";
|
||||
import {
|
||||
collectionFormAtom,
|
||||
collectionsAtom,
|
||||
connectionStateAtom,
|
||||
createCollectionAtom,
|
||||
createCollectionDialogOpenAtom,
|
||||
deleteCollectionAtom,
|
||||
deleteCollectionDialogOpenAtom,
|
||||
flowIdAtom,
|
||||
flowsAtom,
|
||||
resultData,
|
||||
settingsAtom,
|
||||
settingsShowApiKeyAtom,
|
||||
setSettingsFieldAtom,
|
||||
themeAtom,
|
||||
toggleThemeAtom,
|
||||
updateFeatureSwitchesAtom,
|
||||
} from "@/atoms/workbench";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Dialog } from "@/components/ui/dialog";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const ACRONYMS: Record<string, string> = { mcp: "MCP", llm: "LLM", api: "API" };
|
||||
|
||||
/** Convert camelCase key to display label, preserving known acronyms. */
|
||||
function featureLabel(key: string): string {
|
||||
return key
|
||||
.replace(/([A-Z])/g, " $1")
|
||||
.trim()
|
||||
.split(" ")
|
||||
.map((w) => ACRONYMS[w.toLowerCase()] ?? w.charAt(0).toUpperCase() + w.slice(1))
|
||||
.map((word) => ACRONYMS[word.toLowerCase()] ?? word.charAt(0).toUpperCase() + word.slice(1))
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Section wrapper
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function Section({
|
||||
title,
|
||||
icon,
|
||||
|
|
@ -65,181 +67,27 @@ function Section({
|
|||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Settings page
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export default function SettingsPage() {
|
||||
const { settings, updateSetting, updateFeatureSwitches } = useSettings();
|
||||
const connectionState = useConnectionState();
|
||||
const socket = useSocket();
|
||||
const { flows } = useFlows();
|
||||
const notify = useNotification();
|
||||
const settings = useAtomValue(settingsAtom);
|
||||
const setField = useAtomSet(setSettingsFieldAtom);
|
||||
const updateFeatureSwitches = useAtomSet(updateFeatureSwitchesAtom);
|
||||
const connectionState = useAtomValue(connectionStateAtom);
|
||||
const flows = resultData(useAtomValue(flowsAtom), []);
|
||||
const [flowId, setFlowId] = useAtom(flowIdAtom);
|
||||
const [showApiKey, setShowApiKey] = useAtom(settingsShowApiKeyAtom);
|
||||
const [theme] = useAtom(themeAtom);
|
||||
const toggleTheme = useAtomSet(toggleThemeAtom);
|
||||
const collections = resultData(useAtomValue(collectionsAtom), []);
|
||||
const [createOpen, setCreateOpen] = useAtom(createCollectionDialogOpenAtom);
|
||||
const [deleteOpen, setDeleteOpen] = useAtom(deleteCollectionDialogOpenAtom);
|
||||
const [collectionForm, setCollectionForm] = useAtom(collectionFormAtom);
|
||||
const createCollection = useAtomSet(createCollectionAtom);
|
||||
const deleteCollection = useAtomSet(deleteCollectionAtom);
|
||||
|
||||
const flowId = useSessionStore((s) => s.flowId);
|
||||
const setFlowId = useSessionStore((s) => s.setFlowId);
|
||||
|
||||
const [showApiKey, setShowApiKey] = useState(false);
|
||||
const [collections, setCollections] = useState<
|
||||
Array<{ id?: string; name?: string; [key: string]: unknown }>
|
||||
>([]);
|
||||
const [loadingCollections, setLoadingCollections] = useState(false);
|
||||
|
||||
// Create-collection dialog state
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [newId, setNewId] = useState("");
|
||||
const [newName, setNewName] = useState("");
|
||||
const [newDescription, setNewDescription] = useState("");
|
||||
const [newTags, setNewTags] = useState("");
|
||||
const [creating, setCreating] = useState(false);
|
||||
|
||||
// Delete-collection confirmation dialog state
|
||||
const [deleteOpen, setDeleteOpen] = useState(false);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
|
||||
// Dark mode toggle -- uses a class on <html>/<body> and persists to localStorage
|
||||
const [isDark, setIsDark] = useState(() => {
|
||||
if (typeof window === "undefined") return true;
|
||||
const saved = localStorage.getItem("tg-theme");
|
||||
if (saved !== null) return saved === "dark";
|
||||
return !document.documentElement.classList.contains("light");
|
||||
});
|
||||
|
||||
const toggleTheme = useCallback(() => {
|
||||
const next = !isDark;
|
||||
setIsDark(next);
|
||||
if (next) {
|
||||
document.documentElement.classList.remove("light");
|
||||
document.body.classList.remove("light");
|
||||
document.body.classList.add("dark");
|
||||
localStorage.setItem("tg-theme", "dark");
|
||||
} else {
|
||||
document.documentElement.classList.add("light");
|
||||
document.body.classList.add("light");
|
||||
document.body.classList.remove("dark");
|
||||
localStorage.setItem("tg-theme", "light");
|
||||
}
|
||||
}, [isDark]);
|
||||
|
||||
// Reusable function to fetch collections from the backend
|
||||
const refreshCollections = useCallback(() => {
|
||||
setLoadingCollections(true);
|
||||
return socket
|
||||
.collectionManagement()
|
||||
.listCollections()
|
||||
.then((cols) => {
|
||||
const list = Array.isArray(cols)
|
||||
? (cols as Array<{ id?: string; collection?: string; name?: string; [key: string]: unknown }>)
|
||||
: [];
|
||||
// Ensure "default" collection is always present
|
||||
const hasDefault = list.some(
|
||||
(c) => (c.collection ?? c.id ?? c.name) === "default",
|
||||
);
|
||||
if (!hasDefault) {
|
||||
list.unshift({ id: "default", collection: "default", name: "default" });
|
||||
}
|
||||
setCollections(list);
|
||||
return list;
|
||||
})
|
||||
.catch(() => {
|
||||
// Fallback: at minimum show "default"
|
||||
setCollections([{ id: "default", collection: "default", name: "default" }]);
|
||||
})
|
||||
.finally(() => {
|
||||
setLoadingCollections(false);
|
||||
});
|
||||
}, [socket]);
|
||||
|
||||
// Fetch collections on mount
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
refreshCollections().then(() => {
|
||||
if (cancelled) return;
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [refreshCollections]);
|
||||
|
||||
// Create a new collection
|
||||
const handleCreateCollection = useCallback(async () => {
|
||||
const trimmedId = newId.trim();
|
||||
if (trimmedId.length === 0) return;
|
||||
|
||||
setCreating(true);
|
||||
try {
|
||||
const tags = newTags
|
||||
.split(",")
|
||||
.map((t) => t.trim())
|
||||
.filter((tag) => tag.length > 0);
|
||||
|
||||
await socket
|
||||
.collectionManagement()
|
||||
.updateCollection(
|
||||
trimmedId,
|
||||
newName.trim().length > 0 ? newName.trim() : undefined,
|
||||
newDescription.trim().length > 0 ? newDescription.trim() : undefined,
|
||||
tags.length > 0 ? tags : undefined,
|
||||
);
|
||||
|
||||
await refreshCollections();
|
||||
updateSetting("collection", trimmedId);
|
||||
notify.success("Collection created", `"${newName.trim() || trimmedId}" is now active.`);
|
||||
|
||||
// Reset form and close
|
||||
setNewId("");
|
||||
setNewName("");
|
||||
setNewDescription("");
|
||||
setNewTags("");
|
||||
setCreateOpen(false);
|
||||
} catch (err) {
|
||||
notify.error(
|
||||
"Failed to create collection",
|
||||
err instanceof Error ? err.message : String(err),
|
||||
);
|
||||
} finally {
|
||||
setCreating(false);
|
||||
}
|
||||
}, [newId, newName, newDescription, newTags, socket, refreshCollections, updateSetting, notify]);
|
||||
|
||||
// Delete the current collection
|
||||
const handleDeleteCollection = useCallback(async () => {
|
||||
const currentId = settings.collection;
|
||||
if (currentId.length === 0) return;
|
||||
|
||||
setDeleting(true);
|
||||
try {
|
||||
await socket.collectionManagement().deleteCollection(currentId);
|
||||
await refreshCollections();
|
||||
|
||||
// Switch to the first remaining collection
|
||||
const remaining = collections.filter((c) => {
|
||||
const id = c.id ?? String(c.name ?? c);
|
||||
return id !== currentId;
|
||||
});
|
||||
if (remaining.length > 0) {
|
||||
const firstId = remaining[0].id ?? String(remaining[0].name ?? remaining[0]);
|
||||
updateSetting("collection", firstId);
|
||||
}
|
||||
|
||||
notify.success("Collection deleted", `"${currentId}" has been removed.`);
|
||||
setDeleteOpen(false);
|
||||
} catch (err) {
|
||||
notify.error(
|
||||
"Failed to delete collection",
|
||||
err instanceof Error ? err.message : String(err),
|
||||
);
|
||||
} finally {
|
||||
setDeleting(false);
|
||||
}
|
||||
}, [settings.collection, socket, refreshCollections, collections, updateSetting, notify]);
|
||||
|
||||
// Connection status helpers
|
||||
const isConnected =
|
||||
connectionState.status === "connected" ||
|
||||
connectionState.status === "authenticated" ||
|
||||
connectionState.status === "unauthenticated";
|
||||
|
||||
const isWarning = connectionState.status === "unauthenticated";
|
||||
const statusBadge = isConnected ? (
|
||||
<Badge variant={isWarning ? "info" : "success"}>
|
||||
|
|
@ -253,418 +101,235 @@ export default function SettingsPage() {
|
|||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
{/* Header */}
|
||||
<div className="mb-6 flex items-center gap-3">
|
||||
<SettingsIcon className="h-6 w-6 text-brand-400" />
|
||||
<h1 className="text-2xl font-bold text-fg">Settings</h1>
|
||||
</div>
|
||||
|
||||
{/* Form */}
|
||||
<div className="max-w-2xl space-y-5 pb-8 overflow-y-auto">
|
||||
{/* Connection */}
|
||||
<Section
|
||||
title="Connection"
|
||||
icon={<Wifi className="h-4 w-4 text-fg-subtle" />}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-sm text-fg-muted">Status:</span>
|
||||
<div className="grid gap-6 lg:grid-cols-2">
|
||||
<Section title="Connection" icon={<Wifi className="h-4 w-4 text-brand-400" />}>
|
||||
<div className="flex items-center justify-between rounded-lg border border-border bg-surface-100 px-4 py-3">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-fg">Gateway status</p>
|
||||
{connectionState.lastError !== undefined && (
|
||||
<p className="mt-0.5 text-xs text-error">{connectionState.lastError}</p>
|
||||
)}
|
||||
</div>
|
||||
{statusBadge}
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label htmlFor="settings-gateway-url" className="block text-sm font-medium text-fg-muted">
|
||||
Gateway URL
|
||||
</label>
|
||||
<input
|
||||
id="settings-gateway-url"
|
||||
type="text"
|
||||
value={settings.gatewayUrl}
|
||||
onChange={(e) => updateSetting("gatewayUrl", e.target.value)}
|
||||
placeholder="Leave blank to use the default proxy"
|
||||
className="w-full rounded-lg border border-border bg-surface-100 px-4 py-2 text-sm text-fg placeholder:text-fg-subtle focus:border-brand-500 focus:outline-none focus:ring-1 focus:ring-brand-500"
|
||||
/>
|
||||
<p className="text-xs text-fg-subtle">
|
||||
The WebSocket URL for the Beep Graph gateway.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label htmlFor="settings-user-id" className="block text-sm font-medium text-fg-muted">
|
||||
User ID
|
||||
</label>
|
||||
<input
|
||||
id="settings-user-id"
|
||||
type="text"
|
||||
value={settings.user}
|
||||
onChange={(e) => updateSetting("user", e.target.value)}
|
||||
className="w-full rounded-lg border border-border bg-surface-100 px-4 py-2 text-sm text-fg placeholder:text-fg-subtle focus:border-brand-500 focus:outline-none focus:ring-1 focus:ring-brand-500"
|
||||
/>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{/* Authentication */}
|
||||
<Section
|
||||
title="Authentication"
|
||||
icon={<Key className="h-4 w-4 text-fg-subtle" />}
|
||||
>
|
||||
<div className="space-y-1.5">
|
||||
<label htmlFor="settings-api-key" className="block text-sm font-medium text-fg-muted">
|
||||
API Key
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="mb-1 flex items-center gap-1.5 text-sm font-medium text-fg-muted">
|
||||
<Key className="h-3.5 w-3.5" /> API Key
|
||||
</span>
|
||||
<div className="relative">
|
||||
<input
|
||||
id="settings-api-key"
|
||||
type={showApiKey ? "text" : "password"}
|
||||
value={settings.apiKey}
|
||||
onChange={(e) => updateSetting("apiKey", e.target.value)}
|
||||
placeholder="Leave blank for unauthenticated access"
|
||||
className="w-full rounded-lg border border-border bg-surface-100 px-4 py-2 pr-10 text-sm text-fg placeholder:text-fg-subtle focus:border-brand-500 focus:outline-none focus:ring-1 focus:ring-brand-500"
|
||||
onChange={(event) => setField({ key: "apiKey", value: event.target.value })}
|
||||
placeholder="Optional gateway bearer token"
|
||||
className="w-full rounded-lg border border-border bg-surface-100 px-3 py-2 pr-10 text-sm text-fg placeholder:text-fg-subtle focus:border-brand-500 focus:outline-none focus:ring-1 focus:ring-brand-500"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowApiKey((p) => !p)}
|
||||
onClick={() => setShowApiKey(!showApiKey)}
|
||||
aria-label={showApiKey ? "Hide API key" : "Show API key"}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-fg-subtle hover:text-fg"
|
||||
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-fg-subtle hover:text-fg"
|
||||
>
|
||||
{showApiKey ? (
|
||||
<EyeOff className="h-4 w-4" />
|
||||
) : (
|
||||
<Eye className="h-4 w-4" />
|
||||
)}
|
||||
{showApiKey ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-xs text-fg-subtle">
|
||||
Changing the API key will reconnect the WebSocket.
|
||||
</p>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<label className="block">
|
||||
<span className="mb-1 block text-sm font-medium text-fg-muted">Gateway URL</span>
|
||||
<input
|
||||
type="text"
|
||||
value={settings.gatewayUrl}
|
||||
onChange={(event) => setField({ key: "gatewayUrl", value: event.target.value })}
|
||||
placeholder="/api/v1/rpc"
|
||||
className="w-full rounded-lg border border-border bg-surface-100 px-3 py-2 text-sm text-fg placeholder:text-fg-subtle focus:border-brand-500 focus:outline-none focus:ring-1 focus:ring-brand-500"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="block">
|
||||
<span className="mb-1 block text-sm font-medium text-fg-muted">User</span>
|
||||
<input
|
||||
type="text"
|
||||
value={settings.user}
|
||||
onChange={(event) => setField({ key: "user", value: event.target.value })}
|
||||
className="w-full rounded-lg border border-border bg-surface-100 px-3 py-2 text-sm text-fg placeholder:text-fg-subtle focus:border-brand-500 focus:outline-none focus:ring-1 focus:ring-brand-500"
|
||||
/>
|
||||
</label>
|
||||
</Section>
|
||||
|
||||
{/* Collection */}
|
||||
<Section
|
||||
title="Collection"
|
||||
icon={<Database className="h-4 w-4 text-fg-subtle" />}
|
||||
>
|
||||
<div className="space-y-1.5">
|
||||
<label htmlFor="settings-collection" className="block text-sm font-medium text-fg-muted">
|
||||
Active Collection
|
||||
</label>
|
||||
{loadingCollections ? (
|
||||
<div className="flex items-center gap-2 py-2 text-xs text-fg-subtle">
|
||||
<Loader2 className="h-3 w-3 animate-spin" /> Loading
|
||||
collections...
|
||||
</div>
|
||||
) : collections.length > 0 ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<select
|
||||
id="settings-collection"
|
||||
value={settings.collection}
|
||||
onChange={(e) => updateSetting("collection", e.target.value)}
|
||||
className="flex-1 rounded-lg border border-border bg-surface-100 px-4 py-2 text-sm text-fg focus:border-brand-500 focus:outline-none focus:ring-1 focus:ring-brand-500"
|
||||
>
|
||||
{collections.map((c) => {
|
||||
const cObj = c as { collection?: string; id?: string; name?: string };
|
||||
const collId = cObj.collection ?? cObj.id ?? String(cObj.name ?? c);
|
||||
const label = cObj.name ?? collId;
|
||||
return (
|
||||
<option key={collId} value={collId}>
|
||||
{label !== collId ? `${label} (${collId})` : collId}
|
||||
</option>
|
||||
);
|
||||
})}
|
||||
</select>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCreateOpen(true)}
|
||||
aria-label="New collection"
|
||||
title="New collection"
|
||||
className="rounded-lg border border-border bg-surface-100 p-2 text-fg-subtle hover:bg-surface-200 hover:text-fg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-500"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
</button>
|
||||
{collections.length > 1 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDeleteOpen(true)}
|
||||
aria-label="Delete collection"
|
||||
title="Delete collection"
|
||||
className="rounded-lg border border-red-500/30 bg-surface-100 p-2 text-red-400 hover:bg-red-500/10 hover:text-red-300 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-red-500"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
id="settings-collection"
|
||||
type="text"
|
||||
value={settings.collection}
|
||||
onChange={(e) => updateSetting("collection", e.target.value)}
|
||||
className="flex-1 rounded-lg border border-border bg-surface-100 px-4 py-2 text-sm text-fg placeholder:text-fg-subtle focus:border-brand-500 focus:outline-none focus:ring-1 focus:ring-brand-500"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCreateOpen(true)}
|
||||
aria-label="New collection"
|
||||
title="New collection"
|
||||
className="rounded-lg border border-border bg-surface-100 p-2 text-fg-subtle hover:bg-surface-200 hover:text-fg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-500"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{/* Create Collection Dialog */}
|
||||
<Dialog
|
||||
open={createOpen}
|
||||
onClose={() => setCreateOpen(false)}
|
||||
title="New Collection"
|
||||
footer={
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCreateOpen(false)}
|
||||
className="rounded-lg border border-border px-4 py-2 text-sm text-fg-muted hover:bg-surface-200"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={newId.trim().length === 0 || creating}
|
||||
onClick={handleCreateCollection}
|
||||
className="inline-flex items-center gap-2 rounded-lg bg-brand-600 px-4 py-2 text-sm font-medium text-white hover:bg-brand-500 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{creating && <Loader2 className="h-3 w-3 animate-spin" />}
|
||||
Create
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-1.5">
|
||||
<label htmlFor="new-collection-id" className="block text-sm font-medium text-fg-muted">
|
||||
Collection ID <span className="text-red-400">*</span>
|
||||
</label>
|
||||
<input
|
||||
id="new-collection-id"
|
||||
type="text"
|
||||
value={newId}
|
||||
onChange={(e) => setNewId(e.target.value)}
|
||||
placeholder="my-collection"
|
||||
className="w-full rounded-lg border border-border bg-surface-100 px-4 py-2 text-sm text-fg placeholder:text-fg-subtle focus:border-brand-500 focus:outline-none focus:ring-1 focus:ring-brand-500"
|
||||
/>
|
||||
<p className="text-xs text-fg-subtle">
|
||||
A unique identifier for this collection.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label htmlFor="new-collection-name" className="block text-sm font-medium text-fg-muted">
|
||||
Display Name
|
||||
</label>
|
||||
<input
|
||||
id="new-collection-name"
|
||||
type="text"
|
||||
value={newName}
|
||||
onChange={(e) => setNewName(e.target.value)}
|
||||
placeholder="My Collection"
|
||||
className="w-full rounded-lg border border-border bg-surface-100 px-4 py-2 text-sm text-fg placeholder:text-fg-subtle focus:border-brand-500 focus:outline-none focus:ring-1 focus:ring-brand-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label htmlFor="new-collection-description" className="block text-sm font-medium text-fg-muted">
|
||||
Description
|
||||
</label>
|
||||
<textarea
|
||||
id="new-collection-description"
|
||||
value={newDescription}
|
||||
onChange={(e) => setNewDescription(e.target.value)}
|
||||
placeholder="What this collection is for..."
|
||||
rows={3}
|
||||
className="w-full rounded-lg border border-border bg-surface-100 px-4 py-2 text-sm text-fg placeholder:text-fg-subtle focus:border-brand-500 focus:outline-none focus:ring-1 focus:ring-brand-500 resize-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label htmlFor="new-collection-tags" className="block text-sm font-medium text-fg-muted">
|
||||
Tags
|
||||
</label>
|
||||
<input
|
||||
id="new-collection-tags"
|
||||
type="text"
|
||||
value={newTags}
|
||||
onChange={(e) => setNewTags(e.target.value)}
|
||||
placeholder="research, finance, internal"
|
||||
className="w-full rounded-lg border border-border bg-surface-100 px-4 py-2 text-sm text-fg placeholder:text-fg-subtle focus:border-brand-500 focus:outline-none focus:ring-1 focus:ring-brand-500"
|
||||
/>
|
||||
<p className="text-xs text-fg-subtle">
|
||||
Comma-separated list of tags for categorization.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
|
||||
{/* Delete Collection Confirmation Dialog */}
|
||||
<Dialog
|
||||
open={deleteOpen}
|
||||
onClose={() => setDeleteOpen(false)}
|
||||
title="Delete Collection"
|
||||
className="max-w-md"
|
||||
footer={
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDeleteOpen(false)}
|
||||
className="rounded-lg border border-border px-4 py-2 text-sm text-fg-muted hover:bg-surface-200"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={deleting}
|
||||
onClick={handleDeleteCollection}
|
||||
className="inline-flex items-center gap-2 rounded-lg bg-red-600 px-4 py-2 text-sm font-medium text-white hover:bg-red-500 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{deleting && <Loader2 className="h-3 w-3 animate-spin" />}
|
||||
Delete
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<p className="text-sm text-fg-muted">
|
||||
Are you sure you want to delete the collection{" "}
|
||||
<span className="font-semibold text-fg">"{settings.collection}"</span>?
|
||||
This will remove the collection and all its data. This action cannot be undone.
|
||||
</p>
|
||||
</Dialog>
|
||||
|
||||
{/* Flow */}
|
||||
<Section
|
||||
title="Active Flow"
|
||||
icon={<Workflow className="h-4 w-4 text-fg-subtle" />}
|
||||
>
|
||||
<div className="space-y-1.5">
|
||||
<label htmlFor="settings-flow" className="block text-sm font-medium text-fg-muted">
|
||||
Flow
|
||||
</label>
|
||||
{flows.length > 0 ? (
|
||||
<Section title="Workspace" icon={<Database className="h-4 w-4 text-brand-400" />}>
|
||||
<label className="block">
|
||||
<span className="mb-1 block text-sm font-medium text-fg-muted">Collection</span>
|
||||
<div className="flex gap-2">
|
||||
<select
|
||||
id="settings-flow"
|
||||
value={flowId}
|
||||
onChange={(e) => setFlowId(e.target.value)}
|
||||
className="w-full rounded-lg border border-border bg-surface-100 px-4 py-2 text-sm text-fg focus:border-brand-500 focus:outline-none focus:ring-1 focus:ring-brand-500"
|
||||
value={settings.collection}
|
||||
onChange={(event) => setField({ key: "collection", value: event.target.value })}
|
||||
className="min-w-0 flex-1 rounded-lg border border-border bg-surface-100 px-3 py-2 text-sm text-fg focus:border-brand-500 focus:outline-none focus:ring-1 focus:ring-brand-500"
|
||||
>
|
||||
<option value="default">default</option>
|
||||
{flows.map((f) => (
|
||||
<option key={f.id} value={f.id}>
|
||||
{f.id}
|
||||
{f.description !== undefined && f.description.length > 0 ? ` -- ${f.description}` : ""}
|
||||
</option>
|
||||
))}
|
||||
{collections.map((collection) => {
|
||||
const id = String(collection.collection ?? collection.id ?? collection.name ?? "default");
|
||||
return (
|
||||
<option key={id} value={id}>
|
||||
{id}
|
||||
</option>
|
||||
);
|
||||
})}
|
||||
</select>
|
||||
) : (
|
||||
<input
|
||||
id="settings-flow"
|
||||
type="text"
|
||||
value={flowId}
|
||||
onChange={(e) => setFlowId(e.target.value)}
|
||||
placeholder="default"
|
||||
className="w-full rounded-lg border border-border bg-surface-100 px-4 py-2 text-sm text-fg placeholder:text-fg-subtle focus:border-brand-500 focus:outline-none focus:ring-1 focus:ring-brand-500"
|
||||
/>
|
||||
)}
|
||||
<p className="text-xs text-fg-subtle">
|
||||
The flow ID used for chat, graph queries, and document processing.
|
||||
</p>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{/* Theme */}
|
||||
<Section
|
||||
title="Appearance"
|
||||
icon={isDark ? <Moon className="h-4 w-4 text-fg-subtle" /> : <Sun className="h-4 w-4 text-fg-subtle" />}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-fg">Theme</p>
|
||||
<p className="text-xs text-fg-subtle">
|
||||
Toggle between dark and light mode.
|
||||
</p>
|
||||
<p className="text-xs text-fg-subtle">
|
||||
Currently using {isDark ? "dark" : "light"} mode.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCreateOpen(true)}
|
||||
className="rounded-lg border border-border px-3 py-2 text-fg-muted hover:bg-surface-200 hover:text-fg"
|
||||
aria-label="Create collection"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDeleteOpen(true)}
|
||||
className="rounded-lg border border-border px-3 py-2 text-error hover:bg-error/10"
|
||||
aria-label="Delete collection"
|
||||
disabled={settings.collection === "default"}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
role="switch"
|
||||
aria-checked={isDark}
|
||||
aria-label="Dark mode"
|
||||
onClick={toggleTheme}
|
||||
className={cn(
|
||||
"relative inline-flex h-6 w-11 items-center rounded-full transition-colors",
|
||||
isDark ? "bg-brand-600" : "bg-fg-subtle",
|
||||
)}
|
||||
</label>
|
||||
|
||||
<label className="block">
|
||||
<span className="mb-1 flex items-center gap-1.5 text-sm font-medium text-fg-muted">
|
||||
<Workflow className="h-3.5 w-3.5" /> Flow
|
||||
</span>
|
||||
<select
|
||||
value={flowId}
|
||||
onChange={(event) => setFlowId(event.target.value)}
|
||||
className="w-full rounded-lg border border-border bg-surface-100 px-3 py-2 text-sm text-fg focus:border-brand-500 focus:outline-none focus:ring-1 focus:ring-brand-500"
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"inline-block h-4 w-4 rounded-full bg-white transition-transform",
|
||||
isDark ? "translate-x-6" : "translate-x-1",
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
<option value="default">default</option>
|
||||
{flows.map((flow) => (
|
||||
<option key={flow.id} value={flow.id}>
|
||||
{flow.id}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleTheme(null)}
|
||||
className="flex w-full items-center justify-between rounded-lg border border-border bg-surface-100 px-4 py-3 text-sm text-fg hover:bg-surface-200"
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
{theme === "dark" ? <Moon className="h-4 w-4" /> : <Sun className="h-4 w-4" />}
|
||||
Theme
|
||||
</span>
|
||||
<span className="capitalize text-fg-muted">{theme}</span>
|
||||
</button>
|
||||
</Section>
|
||||
|
||||
{/* Feature Switches */}
|
||||
<Section
|
||||
title="Feature Switches"
|
||||
icon={<SettingsIcon className="h-4 w-4 text-fg-subtle" />}
|
||||
>
|
||||
{Object.entries(settings.featureSwitches).map(([key, enabled]) => {
|
||||
const isEnabled = enabled === true;
|
||||
return (
|
||||
<div key={key} className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-fg">{featureLabel(key)}</p>
|
||||
</div>
|
||||
<button
|
||||
role="switch"
|
||||
aria-checked={isEnabled}
|
||||
aria-label={featureLabel(key)}
|
||||
onClick={() => updateFeatureSwitches({ [key]: !isEnabled })}
|
||||
className={cn(
|
||||
"relative inline-flex h-6 w-11 items-center rounded-full transition-colors",
|
||||
isEnabled ? "bg-brand-600" : "bg-fg-subtle",
|
||||
)}
|
||||
>
|
||||
<span className={cn(
|
||||
"inline-block h-4 w-4 rounded-full bg-white transition-transform",
|
||||
isEnabled ? "translate-x-6" : "translate-x-1",
|
||||
)} />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</Section>
|
||||
|
||||
{/* About */}
|
||||
<Section
|
||||
title="About"
|
||||
icon={<Info className="h-4 w-4 text-fg-subtle" />}
|
||||
>
|
||||
<div className="space-y-2 text-sm text-fg-muted">
|
||||
<p>
|
||||
<span className="font-medium text-fg">Beep Graph</span>{" "}
|
||||
v0.1.0
|
||||
</p>
|
||||
<p>
|
||||
A web-based interface for interacting with the Beep Graph
|
||||
knowledge-graph system.
|
||||
</p>
|
||||
<Section title="Feature Switches" icon={<Info className="h-4 w-4 text-brand-400" />}>
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
{Object.entries(settings.featureSwitches).map(([key, value]) => (
|
||||
<label
|
||||
key={key}
|
||||
className="flex items-center justify-between rounded-lg border border-border bg-surface-100 px-3 py-2 text-sm"
|
||||
>
|
||||
<span className="text-fg-muted">{featureLabel(key)}</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={value}
|
||||
onChange={(event) => updateFeatureSwitches({ [key]: event.target.checked })}
|
||||
className="h-4 w-4 accent-brand-500"
|
||||
/>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</Section>
|
||||
</div>
|
||||
|
||||
<Dialog
|
||||
open={createOpen}
|
||||
onClose={() => setCreateOpen(false)}
|
||||
title="Create Collection"
|
||||
footer={
|
||||
<>
|
||||
<button
|
||||
onClick={() => setCreateOpen(false)}
|
||||
className="rounded-lg border border-border px-4 py-2 text-sm text-fg-muted hover:bg-surface-200"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (collectionForm.id.trim().length === 0) return;
|
||||
createCollection(collectionForm);
|
||||
setCollectionForm({ id: "", name: "", description: "", tags: "", submitting: false });
|
||||
setCreateOpen(false);
|
||||
}}
|
||||
disabled={collectionForm.id.trim().length === 0}
|
||||
className="rounded-lg bg-brand-600 px-4 py-2 text-sm font-medium text-white hover:bg-brand-500 disabled:opacity-40"
|
||||
>
|
||||
Create
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="space-y-3">
|
||||
{[
|
||||
["id", "Collection ID", "research"] as const,
|
||||
["name", "Display Name", "Research"] as const,
|
||||
["description", "Description", "Optional description"] as const,
|
||||
["tags", "Tags", "comma, separated"] as const,
|
||||
].map(([key, label, placeholder]) => (
|
||||
<label key={key} className="block">
|
||||
<span className="mb-1 block text-sm font-medium text-fg-muted">{label}</span>
|
||||
<input
|
||||
value={collectionForm[key]}
|
||||
onChange={(event) => setCollectionForm({ ...collectionForm, [key]: event.target.value })}
|
||||
placeholder={placeholder}
|
||||
className="w-full rounded-lg border border-border bg-surface-100 px-3 py-2 text-sm text-fg placeholder:text-fg-subtle focus:border-brand-500 focus:outline-none focus:ring-1 focus:ring-brand-500"
|
||||
/>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</Dialog>
|
||||
|
||||
<Dialog
|
||||
open={deleteOpen}
|
||||
onClose={() => setDeleteOpen(false)}
|
||||
title="Delete Collection"
|
||||
footer={
|
||||
<>
|
||||
<button
|
||||
onClick={() => setDeleteOpen(false)}
|
||||
className="rounded-lg border border-border px-4 py-2 text-sm text-fg-muted hover:bg-surface-200"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
deleteCollection(settings.collection);
|
||||
setDeleteOpen(false);
|
||||
}}
|
||||
className="rounded-lg bg-error px-4 py-2 text-sm font-medium text-white hover:opacity-90"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<Trash2 className="mt-0.5 h-5 w-5 shrink-0 text-error" />
|
||||
<p className="text-sm text-fg-muted">
|
||||
Delete <span className="font-mono text-fg">{settings.collection}</span> and its data?
|
||||
</p>
|
||||
</div>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,72 +1,22 @@
|
|||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useAtomRefresh, useAtomValue } from "@effect/atom-react";
|
||||
import { Coins, Loader2, RefreshCw } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useSocket } from "@/providers/socket-provider";
|
||||
import { useConnectionState } from "@/providers/socket-provider";
|
||||
import { resultData, resultError, resultLoading, tokenCostsAtom } from "@/atoms/workbench";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface TokenCost {
|
||||
model: string;
|
||||
input_price: number;
|
||||
output_price: number;
|
||||
function formatPrice(price: number) {
|
||||
if (!Number.isFinite(price)) return "--";
|
||||
return `$${price.toFixed(2)}`;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Token Cost page
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export default function TokenCostPage() {
|
||||
const socket = useSocket();
|
||||
const connectionState = useConnectionState();
|
||||
const [costs, setCosts] = useState<TokenCost[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const loadCosts = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const data = await socket.config().getTokenCosts();
|
||||
setCosts(
|
||||
Array.isArray(data)
|
||||
? data.map((d: Record<string, unknown>) => ({
|
||||
model: String(d.model ?? ""),
|
||||
input_price: Number(d.input_price ?? 0),
|
||||
output_price: Number(d.output_price ?? 0),
|
||||
}))
|
||||
: [],
|
||||
);
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
setError(msg);
|
||||
console.error("Failed to load token costs:", err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [socket]);
|
||||
|
||||
// Auto-load when connected
|
||||
useEffect(() => {
|
||||
const connected =
|
||||
connectionState.status === "connected" ||
|
||||
connectionState.status === "authenticated" ||
|
||||
connectionState.status === "unauthenticated";
|
||||
if (connected) {
|
||||
loadCosts();
|
||||
}
|
||||
}, [connectionState.status, loadCosts]);
|
||||
|
||||
const formatPrice = (price: number) => {
|
||||
if (!Number.isFinite(price)) return "--";
|
||||
return `$${price.toFixed(2)}`;
|
||||
};
|
||||
const result = useAtomValue(tokenCostsAtom);
|
||||
const refresh = useAtomRefresh(tokenCostsAtom);
|
||||
const costs = resultData(result, []);
|
||||
const loading = resultLoading(result, costs);
|
||||
const error = resultError(result);
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
{/* Header */}
|
||||
<div className="mb-6 flex flex-wrap items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-3">
|
||||
<Coins className="h-6 w-6 text-brand-400" />
|
||||
|
|
@ -79,7 +29,7 @@ export default function TokenCostPage() {
|
|||
</div>
|
||||
|
||||
<button
|
||||
onClick={loadCosts}
|
||||
onClick={refresh}
|
||||
disabled={loading}
|
||||
className="flex items-center gap-1.5 rounded-lg border border-border px-3 py-2 text-sm text-fg-muted transition-colors hover:bg-surface-200 disabled:opacity-40"
|
||||
>
|
||||
|
|
@ -88,7 +38,6 @@ export default function TokenCostPage() {
|
|||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
{loading && costs.length === 0 && (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="mr-2 h-5 w-5 animate-spin text-fg-subtle" />
|
||||
|
|
@ -96,7 +45,7 @@ export default function TokenCostPage() {
|
|||
</div>
|
||||
)}
|
||||
|
||||
{error !== null && error.length > 0 && (
|
||||
{error !== null && (
|
||||
<p className="mb-4 rounded-lg bg-error/10 px-4 py-2 text-sm text-error">
|
||||
{error}
|
||||
</p>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue