Compare commits
6 Commits
dev
...
test-scree
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bd3e78d896 | ||
|
|
5e023c8f97 | ||
|
|
77f41d0cc6 | ||
|
|
5e8530b263 | ||
|
|
817b80a198 | ||
|
|
fbbd222405 |
@@ -0,0 +1,300 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@/components/atoms/Button/Button";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
ChatCircle,
|
||||
PaperPlaneTilt,
|
||||
SpinnerGap,
|
||||
StopCircle,
|
||||
X,
|
||||
} from "@phosphor-icons/react";
|
||||
import { KeyboardEvent, useRef, useState } from "react";
|
||||
import type { CustomNode } from "../FlowEditor/nodes/CustomNode/CustomNode";
|
||||
import { GraphAction } from "./helpers";
|
||||
import { useBuilderChatPanel } from "./useBuilderChatPanel";
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
isGraphLoaded?: boolean;
|
||||
}
|
||||
|
||||
export function BuilderChatPanel({ className, isGraphLoaded }: Props) {
|
||||
const {
|
||||
isOpen,
|
||||
handleToggle,
|
||||
messages,
|
||||
sendMessage,
|
||||
stop,
|
||||
status,
|
||||
isCreatingSession,
|
||||
sessionError,
|
||||
nodes,
|
||||
parsedActions,
|
||||
handleApplyAction,
|
||||
} = useBuilderChatPanel({ isGraphLoaded });
|
||||
|
||||
const [inputValue, setInputValue] = useState("");
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
const isStreaming = status === "streaming" || status === "submitted";
|
||||
|
||||
function handleSend() {
|
||||
const text = inputValue.trim();
|
||||
if (!text || isStreaming) return;
|
||||
setInputValue("");
|
||||
sendMessage({ text });
|
||||
setTimeout(() => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||
}, 50);
|
||||
}
|
||||
|
||||
function handleKeyDown(e: KeyboardEvent<HTMLTextAreaElement>) {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSend();
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"pointer-events-none fixed bottom-4 right-4 z-50 flex flex-col items-end gap-2",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{isOpen && (
|
||||
<div className="pointer-events-auto flex h-[70vh] w-96 flex-col overflow-hidden rounded-xl border border-slate-200 bg-white shadow-2xl">
|
||||
<PanelHeader onClose={handleToggle} />
|
||||
|
||||
<MessageList
|
||||
messages={messages}
|
||||
isCreatingSession={isCreatingSession}
|
||||
sessionError={sessionError}
|
||||
nodes={nodes}
|
||||
parsedActions={parsedActions}
|
||||
onApplyAction={handleApplyAction}
|
||||
messagesEndRef={messagesEndRef}
|
||||
/>
|
||||
|
||||
<PanelInput
|
||||
value={inputValue}
|
||||
onChange={setInputValue}
|
||||
onKeyDown={handleKeyDown}
|
||||
onSend={handleSend}
|
||||
onStop={stop}
|
||||
isStreaming={isStreaming}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={handleToggle}
|
||||
className={cn(
|
||||
"pointer-events-auto flex h-12 w-12 items-center justify-center rounded-full shadow-lg transition-colors",
|
||||
isOpen
|
||||
? "bg-slate-800 text-white hover:bg-slate-700"
|
||||
: "border border-slate-200 bg-white text-slate-700 hover:bg-slate-50",
|
||||
)}
|
||||
aria-label={isOpen ? "Close chat" : "Chat with builder"}
|
||||
>
|
||||
{isOpen ? <X size={20} /> : <ChatCircle size={22} weight="fill" />}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PanelHeader({ onClose }: { onClose: () => void }) {
|
||||
return (
|
||||
<div className="flex items-center justify-between border-b border-slate-100 px-4 py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<ChatCircle size={18} weight="fill" className="text-violet-600" />
|
||||
<span className="text-sm font-semibold text-slate-800">
|
||||
Chat with Builder
|
||||
</span>
|
||||
</div>
|
||||
<Button variant="icon" size="icon" onClick={onClose} aria-label="Close">
|
||||
<X size={16} />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface MessageListProps {
|
||||
messages: ReturnType<typeof useBuilderChatPanel>["messages"];
|
||||
isCreatingSession: boolean;
|
||||
sessionError: boolean;
|
||||
nodes: CustomNode[];
|
||||
parsedActions: GraphAction[];
|
||||
onApplyAction: (action: GraphAction) => void;
|
||||
messagesEndRef: React.RefObject<HTMLDivElement>;
|
||||
}
|
||||
|
||||
function MessageList({
|
||||
messages,
|
||||
isCreatingSession,
|
||||
sessionError,
|
||||
nodes,
|
||||
parsedActions,
|
||||
onApplyAction,
|
||||
messagesEndRef,
|
||||
}: MessageListProps) {
|
||||
return (
|
||||
<div className="flex-1 space-y-3 overflow-y-auto p-4">
|
||||
{isCreatingSession && (
|
||||
<div className="flex items-center gap-2 text-xs text-slate-500">
|
||||
<SpinnerGap size={14} className="animate-spin" />
|
||||
<span>Setting up chat session…</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{sessionError && (
|
||||
<div className="rounded-lg border border-red-100 bg-red-50 px-3 py-2 text-xs text-red-600">
|
||||
Failed to start chat session. Please close and try again.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{messages.map((msg) => {
|
||||
const textParts = msg.parts
|
||||
.filter(
|
||||
(p): p is Extract<typeof p, { type: "text" }> => p.type === "text",
|
||||
)
|
||||
.map((p) => p.text)
|
||||
.join("");
|
||||
|
||||
if (!textParts) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={msg.id}
|
||||
className={cn(
|
||||
"max-w-[85%] rounded-lg px-3 py-2 text-sm leading-relaxed",
|
||||
msg.role === "user"
|
||||
? "ml-auto bg-violet-600 text-white"
|
||||
: "bg-slate-100 text-slate-800",
|
||||
)}
|
||||
>
|
||||
{textParts}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{parsedActions.length > 0 && (
|
||||
<div className="space-y-2 rounded-lg border border-violet-100 bg-violet-50 p-3">
|
||||
<p className="text-xs font-medium text-violet-700">
|
||||
Suggested changes
|
||||
</p>
|
||||
{parsedActions.map((action) => {
|
||||
const key =
|
||||
action.type === "update_node_input"
|
||||
? `${action.nodeId}:${action.key}`
|
||||
: `${action.source}:${action.sourceHandle}->${action.target}:${action.targetHandle}`;
|
||||
return (
|
||||
<ActionItem
|
||||
key={key}
|
||||
action={action}
|
||||
nodes={nodes}
|
||||
onApply={() => onApplyAction(action)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ActionItem({
|
||||
action,
|
||||
nodes,
|
||||
onApply,
|
||||
}: {
|
||||
action: GraphAction;
|
||||
nodes: CustomNode[];
|
||||
onApply: () => void;
|
||||
}) {
|
||||
const [applied, setApplied] = useState(false);
|
||||
|
||||
function handleApply() {
|
||||
onApply();
|
||||
setApplied(true);
|
||||
}
|
||||
|
||||
const nodeName = (id: string) =>
|
||||
nodes.find((n) => n.id === id)?.data.title ?? id;
|
||||
|
||||
const label =
|
||||
action.type === "update_node_input"
|
||||
? `Set "${nodeName(action.nodeId)}" "${action.key}" = ${JSON.stringify(action.value)}`
|
||||
: `Connect "${nodeName(action.source)}" → "${nodeName(action.target)}"`;
|
||||
|
||||
return (
|
||||
<div className="flex items-start justify-between gap-2 rounded bg-white p-2 text-xs shadow-sm">
|
||||
<span className="leading-tight text-slate-700">{label}</span>
|
||||
<button
|
||||
onClick={handleApply}
|
||||
disabled={applied}
|
||||
className={cn(
|
||||
"shrink-0 rounded px-2 py-0.5 text-xs font-medium transition-colors",
|
||||
applied
|
||||
? "bg-green-100 text-green-700"
|
||||
: "bg-violet-600 text-white hover:bg-violet-700",
|
||||
)}
|
||||
>
|
||||
{applied ? "Applied" : "Apply"}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface PanelInputProps {
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
onKeyDown: (e: KeyboardEvent<HTMLTextAreaElement>) => void;
|
||||
onSend: () => void;
|
||||
onStop: () => void;
|
||||
isStreaming: boolean;
|
||||
}
|
||||
|
||||
function PanelInput({
|
||||
value,
|
||||
onChange,
|
||||
onKeyDown,
|
||||
onSend,
|
||||
onStop,
|
||||
isStreaming,
|
||||
}: PanelInputProps) {
|
||||
return (
|
||||
<div className="border-t border-slate-100 p-3">
|
||||
<div className="flex items-end gap-2">
|
||||
<textarea
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
onKeyDown={onKeyDown}
|
||||
placeholder="Ask about your agent…"
|
||||
rows={2}
|
||||
className="flex-1 resize-none rounded-lg border border-slate-200 bg-slate-50 px-3 py-2 text-sm text-slate-800 placeholder:text-slate-400 focus:border-violet-400 focus:outline-none focus:ring-1 focus:ring-violet-200"
|
||||
/>
|
||||
{isStreaming ? (
|
||||
<button
|
||||
onClick={onStop}
|
||||
className="flex h-9 w-9 items-center justify-center rounded-lg bg-red-100 text-red-600 transition-colors hover:bg-red-200"
|
||||
aria-label="Stop"
|
||||
>
|
||||
<StopCircle size={18} />
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={onSend}
|
||||
disabled={!value.trim()}
|
||||
className="flex h-9 w-9 items-center justify-center rounded-lg bg-violet-600 text-white transition-colors hover:bg-violet-700 disabled:opacity-40"
|
||||
aria-label="Send"
|
||||
>
|
||||
<PaperPlaneTilt size={18} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,317 @@
|
||||
import {
|
||||
render,
|
||||
screen,
|
||||
fireEvent,
|
||||
cleanup,
|
||||
} from "@/tests/integrations/test-utils";
|
||||
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
|
||||
import { BuilderChatPanel } from "../BuilderChatPanel";
|
||||
import { serializeGraphForChat, parseGraphActions } from "../helpers";
|
||||
import type { CustomNode } from "../../FlowEditor/nodes/CustomNode/CustomNode";
|
||||
import type { CustomEdge } from "../../FlowEditor/edges/CustomEdge";
|
||||
|
||||
// Mock the hook so we isolate the component rendering
|
||||
vi.mock("../useBuilderChatPanel", () => ({
|
||||
useBuilderChatPanel: vi.fn(),
|
||||
}));
|
||||
|
||||
import { useBuilderChatPanel } from "../useBuilderChatPanel";
|
||||
|
||||
const mockUseBuilderChatPanel = vi.mocked(useBuilderChatPanel);
|
||||
|
||||
function makeMockHook(
|
||||
overrides: Partial<ReturnType<typeof useBuilderChatPanel>> = {},
|
||||
): ReturnType<typeof useBuilderChatPanel> {
|
||||
return {
|
||||
isOpen: false,
|
||||
handleToggle: vi.fn(),
|
||||
messages: [],
|
||||
sendMessage: vi.fn(),
|
||||
stop: vi.fn(),
|
||||
status: "ready",
|
||||
isCreatingSession: false,
|
||||
sessionError: false,
|
||||
sessionId: null,
|
||||
nodes: [],
|
||||
parsedActions: [],
|
||||
handleApplyAction: vi.fn(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
mockUseBuilderChatPanel.mockReturnValue(makeMockHook());
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
describe("BuilderChatPanel", () => {
|
||||
it("renders the toggle button when closed", () => {
|
||||
render(<BuilderChatPanel />);
|
||||
expect(screen.getByLabelText("Chat with builder")).toBeDefined();
|
||||
});
|
||||
|
||||
it("does not render the panel content when closed", () => {
|
||||
render(<BuilderChatPanel />);
|
||||
expect(screen.queryByText("Chat with Builder")).toBeNull();
|
||||
});
|
||||
|
||||
it("calls handleToggle when the toggle button is clicked", () => {
|
||||
const handleToggle = vi.fn();
|
||||
mockUseBuilderChatPanel.mockReturnValue(makeMockHook({ handleToggle }));
|
||||
render(<BuilderChatPanel />);
|
||||
fireEvent.click(screen.getByLabelText("Chat with builder"));
|
||||
expect(handleToggle).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("renders the panel when isOpen is true", () => {
|
||||
mockUseBuilderChatPanel.mockReturnValue(makeMockHook({ isOpen: true }));
|
||||
render(<BuilderChatPanel />);
|
||||
expect(screen.getByText("Chat with Builder")).toBeDefined();
|
||||
});
|
||||
|
||||
it("shows creating session indicator when isCreatingSession is true", () => {
|
||||
mockUseBuilderChatPanel.mockReturnValue(
|
||||
makeMockHook({ isOpen: true, isCreatingSession: true }),
|
||||
);
|
||||
render(<BuilderChatPanel />);
|
||||
expect(screen.getByText(/Setting up chat session/i)).toBeDefined();
|
||||
});
|
||||
|
||||
it("renders user and assistant messages", () => {
|
||||
mockUseBuilderChatPanel.mockReturnValue(
|
||||
makeMockHook({
|
||||
isOpen: true,
|
||||
messages: [
|
||||
{
|
||||
id: "1",
|
||||
role: "user",
|
||||
parts: [{ type: "text", text: "What does this agent do?" }],
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
role: "assistant",
|
||||
parts: [{ type: "text", text: "This agent searches the web." }],
|
||||
},
|
||||
] as ReturnType<typeof useBuilderChatPanel>["messages"],
|
||||
}),
|
||||
);
|
||||
render(<BuilderChatPanel />);
|
||||
expect(screen.getByText("What does this agent do?")).toBeDefined();
|
||||
expect(screen.getByText("This agent searches the web.")).toBeDefined();
|
||||
});
|
||||
|
||||
it("renders suggested actions with Apply buttons when parsedActions are present", () => {
|
||||
mockUseBuilderChatPanel.mockReturnValue(
|
||||
makeMockHook({
|
||||
isOpen: true,
|
||||
parsedActions: [
|
||||
{
|
||||
type: "update_node_input",
|
||||
nodeId: "1",
|
||||
key: "query",
|
||||
value: "AI news",
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
render(<BuilderChatPanel />);
|
||||
expect(screen.getByText("Suggested changes")).toBeDefined();
|
||||
expect(screen.getByText("Apply")).toBeDefined();
|
||||
});
|
||||
|
||||
it("calls handleApplyAction when Apply is clicked and shows Applied state", () => {
|
||||
const handleApplyAction = vi.fn();
|
||||
const action = {
|
||||
type: "update_node_input" as const,
|
||||
nodeId: "1",
|
||||
key: "query",
|
||||
value: "AI news",
|
||||
};
|
||||
mockUseBuilderChatPanel.mockReturnValue(
|
||||
makeMockHook({
|
||||
isOpen: true,
|
||||
parsedActions: [action],
|
||||
handleApplyAction,
|
||||
}),
|
||||
);
|
||||
render(<BuilderChatPanel />);
|
||||
fireEvent.click(screen.getByText("Apply"));
|
||||
expect(handleApplyAction).toHaveBeenCalledWith(action);
|
||||
expect(screen.getByText("Applied")).toBeDefined();
|
||||
});
|
||||
|
||||
it("calls sendMessage when the user submits a message", () => {
|
||||
const sendMessage = vi.fn();
|
||||
mockUseBuilderChatPanel.mockReturnValue(
|
||||
makeMockHook({ isOpen: true, sessionId: "sess-1", sendMessage }),
|
||||
);
|
||||
render(<BuilderChatPanel />);
|
||||
const textarea = screen.getByPlaceholderText("Ask about your agent…");
|
||||
fireEvent.change(textarea, { target: { value: "Add a summarizer block" } });
|
||||
fireEvent.click(screen.getByLabelText("Send"));
|
||||
expect(sendMessage).toHaveBeenCalledWith({
|
||||
text: "Add a summarizer block",
|
||||
});
|
||||
});
|
||||
|
||||
it("shows Stop button when streaming", () => {
|
||||
const stop = vi.fn();
|
||||
mockUseBuilderChatPanel.mockReturnValue(
|
||||
makeMockHook({ isOpen: true, status: "streaming", stop }),
|
||||
);
|
||||
render(<BuilderChatPanel />);
|
||||
expect(screen.getByLabelText("Stop")).toBeDefined();
|
||||
fireEvent.click(screen.getByLabelText("Stop"));
|
||||
expect(stop).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
|
||||
describe("serializeGraphForChat", () => {
|
||||
it("returns empty message when no nodes", () => {
|
||||
const result = serializeGraphForChat([], []);
|
||||
expect(result).toBe("The graph is currently empty.");
|
||||
});
|
||||
|
||||
it("lists block names and descriptions", () => {
|
||||
const nodes = [
|
||||
{
|
||||
id: "1",
|
||||
data: {
|
||||
title: "Google Search",
|
||||
description: "Searches the web",
|
||||
hardcodedValues: {},
|
||||
inputSchema: {},
|
||||
outputSchema: {},
|
||||
uiType: 1,
|
||||
block_id: "block-1",
|
||||
costs: [],
|
||||
categories: [],
|
||||
},
|
||||
type: "custom" as const,
|
||||
position: { x: 0, y: 0 },
|
||||
},
|
||||
] as unknown as CustomNode[];
|
||||
|
||||
const result = serializeGraphForChat(nodes, []);
|
||||
expect(result).toContain('"Google Search"');
|
||||
expect(result).toContain("Searches the web");
|
||||
});
|
||||
|
||||
it("lists connections between nodes", () => {
|
||||
const nodes = [
|
||||
{
|
||||
id: "1",
|
||||
data: {
|
||||
title: "Search",
|
||||
description: "",
|
||||
hardcodedValues: {},
|
||||
inputSchema: {},
|
||||
outputSchema: {},
|
||||
uiType: 1,
|
||||
block_id: "b1",
|
||||
costs: [],
|
||||
categories: [],
|
||||
},
|
||||
type: "custom" as const,
|
||||
position: { x: 0, y: 0 },
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
data: {
|
||||
title: "Formatter",
|
||||
description: "",
|
||||
hardcodedValues: {},
|
||||
inputSchema: {},
|
||||
outputSchema: {},
|
||||
uiType: 1,
|
||||
block_id: "b2",
|
||||
costs: [],
|
||||
categories: [],
|
||||
},
|
||||
type: "custom" as const,
|
||||
position: { x: 200, y: 0 },
|
||||
},
|
||||
] as unknown as CustomNode[];
|
||||
|
||||
const edges = [
|
||||
{
|
||||
id: "1:result->2:input",
|
||||
source: "1",
|
||||
target: "2",
|
||||
sourceHandle: "result",
|
||||
targetHandle: "input",
|
||||
type: "custom" as const,
|
||||
},
|
||||
] as unknown as CustomEdge[];
|
||||
|
||||
const result = serializeGraphForChat(nodes, edges);
|
||||
expect(result).toContain("Connections");
|
||||
expect(result).toContain('"Search"');
|
||||
expect(result).toContain('"Formatter"');
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseGraphActions", () => {
|
||||
it("returns empty array for plain text", () => {
|
||||
expect(parseGraphActions("This agent searches the web.")).toEqual([]);
|
||||
});
|
||||
|
||||
it("parses update_node_input action", () => {
|
||||
const text = `
|
||||
Here is a suggestion:
|
||||
\`\`\`json
|
||||
{"action": "update_node_input", "node_id": "1", "key": "query", "value": "AI news"}
|
||||
\`\`\`
|
||||
`;
|
||||
const actions = parseGraphActions(text);
|
||||
expect(actions).toHaveLength(1);
|
||||
expect(actions[0]).toEqual({
|
||||
type: "update_node_input",
|
||||
nodeId: "1",
|
||||
key: "query",
|
||||
value: "AI news",
|
||||
});
|
||||
});
|
||||
|
||||
it("parses connect_nodes action", () => {
|
||||
const text = `
|
||||
\`\`\`json
|
||||
{"action": "connect_nodes", "source": "1", "target": "2", "source_handle": "result", "target_handle": "input"}
|
||||
\`\`\`
|
||||
`;
|
||||
const actions = parseGraphActions(text);
|
||||
expect(actions).toHaveLength(1);
|
||||
expect(actions[0]).toEqual({
|
||||
type: "connect_nodes",
|
||||
source: "1",
|
||||
target: "2",
|
||||
sourceHandle: "result",
|
||||
targetHandle: "input",
|
||||
});
|
||||
});
|
||||
|
||||
it("ignores invalid JSON blocks", () => {
|
||||
const text = "```json\nnot valid json\n```";
|
||||
expect(parseGraphActions(text)).toEqual([]);
|
||||
});
|
||||
|
||||
it("ignores blocks without action field", () => {
|
||||
const text = '```json\n{"key": "value"}\n```';
|
||||
expect(parseGraphActions(text)).toEqual([]);
|
||||
});
|
||||
|
||||
it("ignores update_node_input actions with missing required fields", () => {
|
||||
const text =
|
||||
'```json\n{"action": "update_node_input", "node_id": "1"}\n```';
|
||||
expect(parseGraphActions(text)).toEqual([]);
|
||||
});
|
||||
|
||||
it("ignores connect_nodes actions with empty handles", () => {
|
||||
const text =
|
||||
'```json\n{"action": "connect_nodes", "source": "1", "target": "2", "source_handle": "", "target_handle": "input"}\n```';
|
||||
expect(parseGraphActions(text)).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,110 @@
|
||||
import type { CustomNode } from "../FlowEditor/nodes/CustomNode/CustomNode";
|
||||
import type { CustomEdge } from "../FlowEditor/edges/CustomEdge";
|
||||
|
||||
export type GraphAction =
|
||||
| {
|
||||
type: "update_node_input";
|
||||
nodeId: string;
|
||||
key: string;
|
||||
value: unknown;
|
||||
}
|
||||
| {
|
||||
type: "connect_nodes";
|
||||
source: string;
|
||||
target: string;
|
||||
sourceHandle: string;
|
||||
targetHandle: string;
|
||||
};
|
||||
|
||||
export function serializeGraphForChat(
|
||||
nodes: CustomNode[],
|
||||
edges: CustomEdge[],
|
||||
): string {
|
||||
if (nodes.length === 0) return "The graph is currently empty.";
|
||||
|
||||
const nodeLines = nodes.map((n) => {
|
||||
const name = n.data.metadata?.customized_name || n.data.title;
|
||||
const desc = n.data.description ? ` — ${n.data.description}` : "";
|
||||
return `- Node ${n.id}: "${name}"${desc}`;
|
||||
});
|
||||
|
||||
const edgeLines = edges.map((e) => {
|
||||
const src = nodes.find((n) => n.id === e.source);
|
||||
const tgt = nodes.find((n) => n.id === e.target);
|
||||
const srcName =
|
||||
src?.data.metadata?.customized_name || src?.data.title || e.source;
|
||||
const tgtName =
|
||||
tgt?.data.metadata?.customized_name || tgt?.data.title || e.target;
|
||||
return `- "${srcName}" (${e.sourceHandle}) → "${tgtName}" (${e.targetHandle})`;
|
||||
});
|
||||
|
||||
const parts = [`Blocks (${nodes.length}):\n${nodeLines.join("\n")}`];
|
||||
if (edgeLines.length > 0) {
|
||||
parts.push(`Connections (${edges.length}):\n${edgeLines.join("\n")}`);
|
||||
}
|
||||
return parts.join("\n\n");
|
||||
}
|
||||
|
||||
export function parseGraphActions(text: string): GraphAction[] {
|
||||
const actions: GraphAction[] = [];
|
||||
const jsonBlockRegex = /```(?:json)?\s*\n?([\s\S]*?)\n?```/g;
|
||||
let match: RegExpExecArray | null;
|
||||
|
||||
while ((match = jsonBlockRegex.exec(text)) !== null) {
|
||||
try {
|
||||
const parsed = JSON.parse(match[1]) as unknown;
|
||||
if (
|
||||
typeof parsed !== "object" ||
|
||||
parsed === null ||
|
||||
!("action" in parsed)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const obj = parsed as Record<string, unknown>;
|
||||
if (obj.action === "update_node_input") {
|
||||
const nodeId = obj.node_id;
|
||||
const key = obj.key;
|
||||
if (
|
||||
typeof nodeId !== "string" ||
|
||||
!nodeId ||
|
||||
typeof key !== "string" ||
|
||||
!key ||
|
||||
obj.value === undefined
|
||||
)
|
||||
continue;
|
||||
actions.push({
|
||||
type: "update_node_input",
|
||||
nodeId,
|
||||
key,
|
||||
value: obj.value,
|
||||
});
|
||||
} else if (obj.action === "connect_nodes") {
|
||||
const source = obj.source;
|
||||
const target = obj.target;
|
||||
const sourceHandle = obj.source_handle;
|
||||
const targetHandle = obj.target_handle;
|
||||
if (
|
||||
typeof source !== "string" ||
|
||||
!source ||
|
||||
typeof target !== "string" ||
|
||||
!target ||
|
||||
typeof sourceHandle !== "string" ||
|
||||
!sourceHandle ||
|
||||
typeof targetHandle !== "string" ||
|
||||
!targetHandle
|
||||
)
|
||||
continue;
|
||||
actions.push({
|
||||
type: "connect_nodes",
|
||||
source,
|
||||
target,
|
||||
sourceHandle,
|
||||
targetHandle,
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// Not valid JSON, skip
|
||||
}
|
||||
}
|
||||
return actions;
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
import { postV2CreateSession } from "@/app/api/__generated__/endpoints/chat/chat";
|
||||
import { getWebSocketToken } from "@/lib/supabase/actions";
|
||||
import { environment } from "@/services/environment";
|
||||
import { useChat } from "@ai-sdk/react";
|
||||
import { DefaultChatTransport } from "ai";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useShallow } from "zustand/react/shallow";
|
||||
import { useEdgeStore } from "../../stores/edgeStore";
|
||||
import { useNodeStore } from "../../stores/nodeStore";
|
||||
import {
|
||||
GraphAction,
|
||||
parseGraphActions,
|
||||
serializeGraphForChat,
|
||||
} from "./helpers";
|
||||
|
||||
type SendMessageFn = ReturnType<typeof useChat>["sendMessage"];
|
||||
|
||||
interface UseBuilderChatPanelArgs {
|
||||
isGraphLoaded?: boolean;
|
||||
}
|
||||
|
||||
export function useBuilderChatPanel({
|
||||
isGraphLoaded = true,
|
||||
}: UseBuilderChatPanelArgs = {}) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [sessionId, setSessionId] = useState<string | null>(null);
|
||||
const [isCreatingSession, setIsCreatingSession] = useState(false);
|
||||
const [sessionError, setSessionError] = useState(false);
|
||||
const initializedRef = useRef(false);
|
||||
const sendMessageRef = useRef<SendMessageFn | null>(null);
|
||||
|
||||
const nodes = useNodeStore(useShallow((s) => s.nodes));
|
||||
const edges = useEdgeStore(useShallow((s) => s.edges));
|
||||
const updateNodeData = useNodeStore(useShallow((s) => s.updateNodeData));
|
||||
const addEdge = useEdgeStore(useShallow((s) => s.addEdge));
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen || sessionId || isCreatingSession || sessionError) return;
|
||||
|
||||
async function createSession() {
|
||||
setIsCreatingSession(true);
|
||||
try {
|
||||
const res = await postV2CreateSession(null);
|
||||
if (res.status === 200) {
|
||||
setSessionId(res.data.id);
|
||||
} else {
|
||||
setSessionError(true);
|
||||
}
|
||||
} catch {
|
||||
setSessionError(true);
|
||||
} finally {
|
||||
setIsCreatingSession(false);
|
||||
}
|
||||
}
|
||||
|
||||
createSession();
|
||||
}, [isOpen, sessionId, isCreatingSession, sessionError]);
|
||||
|
||||
const transport = useMemo(
|
||||
() =>
|
||||
sessionId
|
||||
? new DefaultChatTransport({
|
||||
api: `${environment.getAGPTServerBaseUrl()}/api/chat/sessions/${sessionId}/stream`,
|
||||
prepareSendMessagesRequest: async ({ messages }) => {
|
||||
const last = messages[messages.length - 1];
|
||||
const { token, error } = await getWebSocketToken();
|
||||
if (error || !token)
|
||||
throw new Error(
|
||||
"Authentication failed — please sign in again.",
|
||||
);
|
||||
const messageText =
|
||||
last.parts
|
||||
?.map((p) => (p.type === "text" ? p.text : ""))
|
||||
.join("") ?? "";
|
||||
return {
|
||||
body: {
|
||||
message: messageText,
|
||||
is_user_message: last.role === "user",
|
||||
context: null,
|
||||
file_ids: null,
|
||||
mode: null,
|
||||
},
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
};
|
||||
},
|
||||
})
|
||||
: null,
|
||||
[sessionId],
|
||||
);
|
||||
|
||||
const { messages, sendMessage, stop, status } = useChat({
|
||||
id: sessionId ?? undefined,
|
||||
transport: transport ?? undefined,
|
||||
});
|
||||
|
||||
// Keep a stable ref so the initialization effect can call sendMessage
|
||||
// without including it in the deps array (avoids re-triggering the effect)
|
||||
sendMessageRef.current = sendMessage;
|
||||
|
||||
useEffect(() => {
|
||||
if (!sessionId || !transport || !isGraphLoaded || initializedRef.current)
|
||||
return;
|
||||
initializedRef.current = true;
|
||||
const summary = serializeGraphForChat(nodes, edges);
|
||||
sendMessageRef.current?.({
|
||||
text: `I'm building an agent in the AutoGPT flow builder. Here's the current graph:\n\n${summary}\n\nWhat does this agent do?`,
|
||||
});
|
||||
}, [sessionId, transport, isGraphLoaded]);
|
||||
|
||||
function handleToggle() {
|
||||
setIsOpen((o) => !o);
|
||||
}
|
||||
|
||||
function handleApplyAction(action: GraphAction) {
|
||||
if (action.type === "update_node_input") {
|
||||
const node = nodes.find((n) => n.id === action.nodeId);
|
||||
if (!node) return;
|
||||
updateNodeData(action.nodeId, {
|
||||
hardcodedValues: {
|
||||
...node.data.hardcodedValues,
|
||||
[action.key]: action.value,
|
||||
},
|
||||
});
|
||||
} else if (action.type === "connect_nodes") {
|
||||
addEdge({
|
||||
id: `${action.source}:${action.sourceHandle}->${action.target}:${action.targetHandle}`,
|
||||
source: action.source,
|
||||
target: action.target,
|
||||
sourceHandle: action.sourceHandle,
|
||||
targetHandle: action.targetHandle,
|
||||
type: "custom",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const parsedActions = useMemo(() => {
|
||||
const assistantMessages = messages.filter((m) => m.role === "assistant");
|
||||
const last = assistantMessages[assistantMessages.length - 1];
|
||||
if (!last) return [];
|
||||
const text = last.parts
|
||||
.filter(
|
||||
(p): p is Extract<typeof p, { type: "text" }> => p.type === "text",
|
||||
)
|
||||
.map((p) => p.text)
|
||||
.join("");
|
||||
const parsed = parseGraphActions(text);
|
||||
const seen = new Set<string>();
|
||||
return parsed.filter((action) => {
|
||||
const key =
|
||||
action.type === "update_node_input"
|
||||
? `${action.nodeId}:${action.key}`
|
||||
: `${action.source}:${action.sourceHandle}->${action.target}:${action.targetHandle}`;
|
||||
if (seen.has(key)) return false;
|
||||
seen.add(key);
|
||||
return true;
|
||||
});
|
||||
}, [messages]);
|
||||
|
||||
return {
|
||||
isOpen,
|
||||
handleToggle,
|
||||
messages,
|
||||
sendMessage,
|
||||
stop,
|
||||
status,
|
||||
isCreatingSession,
|
||||
sessionError,
|
||||
sessionId,
|
||||
nodes,
|
||||
parsedActions,
|
||||
handleApplyAction,
|
||||
};
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useGetV1GetSpecificGraph } from "@/app/api/__generated__/endpoints/graphs/graphs";
|
||||
import { okData } from "@/app/api/helpers";
|
||||
import { FloatingReviewsPanel } from "@/components/organisms/FloatingReviewsPanel/FloatingReviewsPanel";
|
||||
import { BuilderChatPanel } from "../../BuilderChatPanel/BuilderChatPanel";
|
||||
import { Background, ReactFlow } from "@xyflow/react";
|
||||
import { parseAsString, useQueryStates } from "nuqs";
|
||||
import { useCallback, useMemo } from "react";
|
||||
@@ -134,6 +135,7 @@ export const Flow = () => {
|
||||
executionId={flowExecutionID || undefined}
|
||||
graphId={flowID || undefined}
|
||||
/>
|
||||
<BuilderChatPanel isGraphLoaded={isInitialLoadComplete} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
BIN
test-screenshots/PR-12699/01-after-login.png
Normal file
|
After Width: | Height: | Size: 86 KiB |
BIN
test-screenshots/PR-12699/02-builder-page.png
Normal file
|
After Width: | Height: | Size: 36 KiB |
BIN
test-screenshots/PR-12699/03-panel-opened.png
Normal file
|
After Width: | Height: | Size: 60 KiB |
BIN
test-screenshots/PR-12699/04-after-session-created.png
Normal file
|
After Width: | Height: | Size: 74 KiB |
BIN
test-screenshots/PR-12699/04-ai-response-visible.png
Normal file
|
After Width: | Height: | Size: 146 KiB |
BIN
test-screenshots/PR-12699/05-ai-applies-json-action.png
Normal file
|
After Width: | Height: | Size: 139 KiB |
BIN
test-screenshots/PR-12699/05-message-sent.png
Normal file
|
After Width: | Height: | Size: 70 KiB |
BIN
test-screenshots/PR-12699/06-applied-button-shown.png
Normal file
|
After Width: | Height: | Size: 139 KiB |
BIN
test-screenshots/PR-12699/06-assistant-response.png
Normal file
|
After Width: | Height: | Size: 69 KiB |
BIN
test-screenshots/PR-12699/07-response-complete.png
Normal file
|
After Width: | Height: | Size: 69 KiB |
BIN
test-screenshots/PR-12699/08-after-modal-dismiss.png
Normal file
|
After Width: | Height: | Size: 75 KiB |
BIN
test-screenshots/PR-12699/09-full-response.png
Normal file
|
After Width: | Height: | Size: 70 KiB |
BIN
test-screenshots/PR-12699/10-panel-closed.png
Normal file
|
After Width: | Height: | Size: 36 KiB |
BIN
test-screenshots/PR-12699/11-builder-with-graph.png
Normal file
|
After Width: | Height: | Size: 105 KiB |
BIN
test-screenshots/PR-12699/12-panel-with-graph.png
Normal file
|
After Width: | Height: | Size: 151 KiB |
BIN
test-screenshots/PR-12699/13-ai-describes-graph.png
Normal file
|
After Width: | Height: | Size: 152 KiB |
BIN
test-screenshots/PR-12699/14-action-suggestions.png
Normal file
|
After Width: | Height: | Size: 137 KiB |
BIN
test-screenshots/PR-12699/15-action-response.png
Normal file
|
After Width: | Height: | Size: 138 KiB |
BIN
test-screenshots/PR-12699/16-streaming-done.png
Normal file
|
After Width: | Height: | Size: 138 KiB |
BIN
test-screenshots/PR-12699/17-stopped-streaming.png
Normal file
|
After Width: | Height: | Size: 138 KiB |
BIN
test-screenshots/PR-12699/18-panel-reopened.png
Normal file
|
After Width: | Height: | Size: 152 KiB |