Compare commits

...

6 Commits

Author SHA1 Message Date
Zamil Majdy
bd3e78d896 test: add E2E screenshots for PR #12699 2026-04-08 02:37:17 +07:00
Zamil Majdy
5e023c8f97 test: add E2E screenshots for PR #12699 2026-04-08 00:02:16 +07:00
Zamil Majdy
77f41d0cc6 fix(frontend/builder): include handles in connect_nodes dedup key 2026-04-07 23:25:20 +07:00
Zamil Majdy
5e8530b263 fix(frontend/builder): address coderabbitai and sentry review feedback
- Validate required fields in parseGraphActions before emitting actions
  (coderabbitai: reject malformed payloads instead of coercing to "")
- Gate chat seeding on isGraphLoaded to avoid seeding with empty graph
  when panel is opened before graph finishes loading (coderabbitai)
- Deduplicate parsedActions in the hook to prevent duplicate React keys
  when AI suggests the same action twice (sentry)
- Add tests for malformed action field validation
2026-04-07 23:16:52 +07:00
Zamil Majdy
817b80a198 fix(frontend/builder): address chat panel review comments
- Prevent infinite retry loop on session creation failure by tracking
  sessionError state and bailing out on non-200 or thrown errors
- Remove nodes/edges from initialization effect deps (only fire once
  when sessionId+transport become available)
- Show node display name instead of raw ID in action item labels
- Use stable content-based keys for action items instead of array index
2026-04-07 23:09:06 +07:00
Zamil Majdy
fbbd222405 feat(frontend/builder): add chat panel for interactive agent editing
Add a collapsible right-side chat panel to the flow builder that lets
users ask questions about their agent and request modifications via chat.
2026-04-07 22:57:21 +07:00
26 changed files with 902 additions and 0 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 146 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 139 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 139 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 151 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 152 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 137 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 152 KiB