diff --git a/.github/workflows/dev-build.yaml b/.github/workflows/dev-build.yaml index bcd509b5c85..1eaf5b03fd1 100644 --- a/.github/workflows/dev-build.yaml +++ b/.github/workflows/dev-build.yaml @@ -6,7 +6,7 @@ concurrency: on: push: - branches: ['3069-tokenizer-collector-improvements'] # put your current branch to create a build. Core team only. + branches: ['agent-builder'] # put your current branch to create a build. Core team only. paths-ignore: - '**.md' - 'cloud-deployments/*' diff --git a/README.md b/README.md index dc15523fadc..eafde9a2b58 100644 --- a/README.md +++ b/README.md @@ -56,9 +56,10 @@ AnythingLLM divides your documents into objects called `workspaces`. A Workspace ## Cool features of AnythingLLM - 🆕 [**Custom AI Agents**](https://docs.anythingllm.com/agent/custom/introduction) +- 🆕 [**No-code AI Agent builder**](https://docs.anythingllm.com/agent-flows/overview) - 🖼️ **Multi-modal support (both closed and open-source LLMs!)** - 👤 Multi-user instance support and permissioning _Docker version only_ -- 🦾 Agents inside your workspace (browse the web, run code, etc) +- 🦾 Agents inside your workspace (browse the web, etc) - 💬 [Custom Embeddable Chat widget for your website](./embed/README.md) _Docker version only_ - 📖 Multiple document type support (PDF, TXT, DOCX, etc) - Simple chat UI with Drag-n-Drop funcitonality and clear citations. diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index af84603cb8e..99104146d74 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -67,6 +67,7 @@ const ExperimentalFeatures = lazy( const LiveDocumentSyncManage = lazy( () => import("@/pages/Admin/ExperimentalFeatures/Features/LiveSync/manage") ); +const AgentBuilder = lazy(() => import("@/pages/Admin/AgentBuilder")); const CommunityHubTrending = lazy( () => import("@/pages/GeneralSettings/CommunityHub/Trending") @@ -143,6 +144,24 @@ export default function App() { path="/settings/agents" element={} /> + + } + /> + + } + /> } diff --git a/frontend/src/components/PrivateRoute/index.jsx b/frontend/src/components/PrivateRoute/index.jsx index 1b4c71fbd94..98308fe4970 100644 --- a/frontend/src/components/PrivateRoute/index.jsx +++ b/frontend/src/components/PrivateRoute/index.jsx @@ -83,7 +83,7 @@ function useIsAuthenticated() { // Allows only admin to access the route and if in single user mode, // allows all users to access the route -export function AdminRoute({ Component }) { +export function AdminRoute({ Component, hideUserMenu = false }) { const { isAuthd, shouldRedirectToOnboarding, multiUserMode } = useIsAuthenticated(); if (isAuthd === null) return ; @@ -94,9 +94,13 @@ export function AdminRoute({ Component }) { const user = userFromStorage(); return isAuthd && (user?.role === "admin" || !multiUserMode) ? ( - + hideUserMenu ? ( - + ) : ( + + + + ) ) : ( ); diff --git a/frontend/src/components/WorkspaceChat/ChatContainer/ChatHistory/HistoricalMessage/index.jsx b/frontend/src/components/WorkspaceChat/ChatContainer/ChatHistory/HistoricalMessage/index.jsx index 8dfcf872ce8..a5d60db4e07 100644 --- a/frontend/src/components/WorkspaceChat/ChatContainer/ChatHistory/HistoricalMessage/index.jsx +++ b/frontend/src/components/WorkspaceChat/ChatContainer/ChatHistory/HistoricalMessage/index.jsx @@ -18,6 +18,10 @@ import { } from "../ThoughtContainer"; const DOMPurify = createDOMPurify(window); +DOMPurify.setConfig({ + ADD_ATTR: ["target", "rel"], +}); + const HistoricalMessage = ({ uuid = v4(), message, diff --git a/frontend/src/components/WorkspaceChat/ChatContainer/ChatHistory/StatusResponse/index.jsx b/frontend/src/components/WorkspaceChat/ChatContainer/ChatHistory/StatusResponse/index.jsx index c06fb35ad1e..2ceb74406f3 100644 --- a/frontend/src/components/WorkspaceChat/ChatContainer/ChatHistory/StatusResponse/index.jsx +++ b/frontend/src/components/WorkspaceChat/ChatContainer/ChatHistory/StatusResponse/index.jsx @@ -71,7 +71,9 @@ export default function StatusResponse({
diff --git a/frontend/src/index.css b/frontend/src/index.css index 4a20843d503..82454fc5a01 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -41,6 +41,8 @@ --theme-button-primary: #46c8ff; --theme-button-primary-hover: #434343; + --theme-button-cta: #7cd4fd; + --theme-file-row-even: #0e0f0f; --theme-file-row-odd: #1b1b1e; --theme-file-row-selected-even: rgba(14, 165, 233, 0.2); @@ -92,6 +94,8 @@ --theme-button-primary: #0ba5ec; --theme-button-primary-hover: #dedede; + --theme-button-cta: #7cd4fd; + --theme-file-row-even: #f5f5f5; --theme-file-row-odd: #e9e9e9; --theme-file-row-selected-even: #0ba5ec; @@ -664,6 +668,11 @@ dialog::backdrop { padding: 14px 15px; } +.markdown > * a { + color: var(--theme-button-cta); + text-decoration: underline; +} + @media (max-width: 600px) { .markdown table th, .markdown table td { diff --git a/frontend/src/media/logo/anything-llm-infinity.png b/frontend/src/media/logo/anything-llm-infinity.png new file mode 100644 index 00000000000..9970b926933 Binary files /dev/null and b/frontend/src/media/logo/anything-llm-infinity.png differ diff --git a/frontend/src/models/agentFlows.js b/frontend/src/models/agentFlows.js new file mode 100644 index 00000000000..fcb82c597ac --- /dev/null +++ b/frontend/src/models/agentFlows.js @@ -0,0 +1,149 @@ +import { API_BASE } from "@/utils/constants"; +import { baseHeaders } from "@/utils/request"; + +const AgentFlows = { + /** + * Save a flow configuration + * @param {string} name - Display name of the flow + * @param {object} config - The configuration object for the flow + * @param {string} [uuid] - Optional UUID for updating existing flow + * @returns {Promise<{success: boolean, error: string | null, flow: {name: string, config: object, uuid: string} | null}>} + */ + saveFlow: async (name, config, uuid = null) => { + return await fetch(`${API_BASE}/agent-flows/save`, { + method: "POST", + headers: { + ...baseHeaders(), + "Content-Type": "application/json", + }, + body: JSON.stringify({ name, config, uuid }), + }) + .then((res) => { + if (!res.ok) throw new Error(response.error || "Failed to save flow"); + return res; + }) + .then((res) => res.json()) + .catch((e) => ({ + success: false, + error: e.message, + flow: null, + })); + }, + + /** + * List all available flows in the system + * @returns {Promise<{success: boolean, error: string | null, flows: Array<{name: string, uuid: string, description: string, steps: Array}>}>} + */ + listFlows: async () => { + return await fetch(`${API_BASE}/agent-flows/list`, { + method: "GET", + headers: baseHeaders(), + }) + .then((res) => res.json()) + .catch((e) => ({ + success: false, + error: e.message, + flows: [], + })); + }, + + /** + * Get a specific flow by UUID + * @param {string} uuid - The UUID of the flow to retrieve + * @returns {Promise<{success: boolean, error: string | null, flow: {name: string, config: object, uuid: string} | null}>} + */ + getFlow: async (uuid) => { + return await fetch(`${API_BASE}/agent-flows/${uuid}`, { + method: "GET", + headers: baseHeaders(), + }) + .then((res) => { + if (!res.ok) throw new Error(response.error || "Failed to get flow"); + return res; + }) + .then((res) => res.json()) + .catch((e) => ({ + success: false, + error: e.message, + flow: null, + })); + }, + + /** + * Execute a specific flow + * @param {string} uuid - The UUID of the flow to run + * @param {object} variables - Optional variables to pass to the flow + * @returns {Promise<{success: boolean, error: string | null, results: object | null}>} + */ + // runFlow: async (uuid, variables = {}) => { + // return await fetch(`${API_BASE}/agent-flows/${uuid}/run`, { + // method: "POST", + // headers: { + // ...baseHeaders(), + // "Content-Type": "application/json", + // }, + // body: JSON.stringify({ variables }), + // }) + // .then((res) => { + // if (!res.ok) throw new Error(response.error || "Failed to run flow"); + // return res; + // }) + // .then((res) => res.json()) + // .catch((e) => ({ + // success: false, + // error: e.message, + // results: null, + // })); + // }, + + /** + * Delete a specific flow + * @param {string} uuid - The UUID of the flow to delete + * @returns {Promise<{success: boolean, error: string | null}>} + */ + deleteFlow: async (uuid) => { + return await fetch(`${API_BASE}/agent-flows/${uuid}`, { + method: "DELETE", + headers: baseHeaders(), + }) + .then((res) => { + if (!res.ok) throw new Error(response.error || "Failed to delete flow"); + return res; + }) + .then((res) => res.json()) + .catch((e) => ({ + success: false, + error: e.message, + })); + }, + + /** + * Toggle a flow's active status + * @param {string} uuid - The UUID of the flow to toggle + * @param {boolean} active - The new active status + * @returns {Promise<{success: boolean, error: string | null}>} + */ + toggleFlow: async (uuid, active) => { + try { + const result = await fetch(`${API_BASE}/agent-flows/${uuid}/toggle`, { + method: "POST", + headers: { + ...baseHeaders(), + "Content-Type": "application/json", + }, + body: JSON.stringify({ active }), + }) + .then((res) => { + if (!res.ok) throw new Error(res.error || "Failed to toggle flow"); + return res; + }) + .then((res) => res.json()); + return { success: true, flow: result.flow }; + } catch (error) { + console.error("Failed to toggle flow:", error); + return { success: false, error: error.message }; + } + }, +}; + +export default AgentFlows; diff --git a/frontend/src/pages/Admin/AgentBuilder/AddBlockMenu/index.jsx b/frontend/src/pages/Admin/AgentBuilder/AddBlockMenu/index.jsx new file mode 100644 index 00000000000..777f521c043 --- /dev/null +++ b/frontend/src/pages/Admin/AgentBuilder/AddBlockMenu/index.jsx @@ -0,0 +1,68 @@ +import React, { useRef, useEffect } from "react"; +import { Plus, CaretDown } from "@phosphor-icons/react"; +import { BLOCK_TYPES, BLOCK_INFO } from "../BlockList"; + +export default function AddBlockMenu({ + showBlockMenu, + setShowBlockMenu, + addBlock, +}) { + const menuRef = useRef(null); + + useEffect(() => { + function handleClickOutside(event) { + if (menuRef.current && !menuRef.current.contains(event.target)) { + setShowBlockMenu(false); + } + } + + document.addEventListener("mousedown", handleClickOutside); + return () => { + document.removeEventListener("mousedown", handleClickOutside); + }; + }, [setShowBlockMenu]); + + return ( +
+ + {showBlockMenu && ( +
+ {Object.entries(BLOCK_INFO).map( + ([type, info]) => + type !== BLOCK_TYPES.START && + type !== BLOCK_TYPES.FINISH && + type !== BLOCK_TYPES.FLOW_INFO && ( + + ) + )} +
+ )} +
+ ); +} diff --git a/frontend/src/pages/Admin/AgentBuilder/BlockList/index.jsx b/frontend/src/pages/Admin/AgentBuilder/BlockList/index.jsx new file mode 100644 index 00000000000..c937f109dd8 --- /dev/null +++ b/frontend/src/pages/Admin/AgentBuilder/BlockList/index.jsx @@ -0,0 +1,305 @@ +import React from "react"; +import { + X, + CaretUp, + CaretDown, + Globe, + Browser, + Brain, + Flag, + Info, + BracketsCurly, +} from "@phosphor-icons/react"; +import { Tooltip } from "react-tooltip"; +import StartNode from "../nodes/StartNode"; +import ApiCallNode from "../nodes/ApiCallNode"; +import WebsiteNode from "../nodes/WebsiteNode"; +import FileNode from "../nodes/FileNode"; +import CodeNode from "../nodes/CodeNode"; +import LLMInstructionNode from "../nodes/LLMInstructionNode"; +import FinishNode from "../nodes/FinishNode"; +import WebScrapingNode from "../nodes/WebScrapingNode"; +import FlowInfoNode from "../nodes/FlowInfoNode"; + +const BLOCK_TYPES = { + FLOW_INFO: "flowInfo", + START: "start", + API_CALL: "apiCall", + // WEBSITE: "website", // Temporarily disabled + // FILE: "file", // Temporarily disabled + // CODE: "code", // Temporarily disabled + LLM_INSTRUCTION: "llmInstruction", + WEB_SCRAPING: "webScraping", + FINISH: "finish", +}; + +const BLOCK_INFO = { + [BLOCK_TYPES.FLOW_INFO]: { + label: "Flow Infomation", + icon: , + description: "Basic flow information", + defaultConfig: { + name: "", + description: "", + }, + getSummary: (config) => config.name || "Untitled Flow", + }, + [BLOCK_TYPES.START]: { + label: "Flow Variables", + icon: , + description: "Configure agent variables and settings", + getSummary: (config) => { + const varCount = config.variables?.filter((v) => v.name)?.length || 0; + return `${varCount} variable${varCount !== 1 ? "s" : ""} defined`; + }, + }, + [BLOCK_TYPES.API_CALL]: { + label: "API Call", + icon: , + description: "Make an HTTP request", + defaultConfig: { + url: "", + method: "GET", + headers: [], + bodyType: "json", + body: "", + formData: [], + responseVariable: "", + }, + getSummary: (config) => + `${config.method || "GET"} ${config.url || "(no URL)"}`, + }, + // TODO: Implement website, file, and code blocks + /* [BLOCK_TYPES.WEBSITE]: { + label: "Open Website", + icon: , + description: "Navigate to a URL", + defaultConfig: { + url: "", + selector: "", + action: "read", + value: "", + resultVariable: "", + }, + getSummary: (config) => + `${config.action || "read"} from ${config.url || "(no URL)"}`, + }, + [BLOCK_TYPES.FILE]: { + label: "Open File", + icon: , + description: "Read or write to a file", + defaultConfig: { + path: "", + operation: "read", + content: "", + resultVariable: "", + }, + getSummary: (config) => + `${config.operation || "read"} ${config.path || "(no path)"}`, + }, + [BLOCK_TYPES.CODE]: { + label: "Code Execution", + icon: , + description: "Execute code snippets", + defaultConfig: { + language: "javascript", + code: "", + resultVariable: "", + }, + getSummary: (config) => `Run ${config.language || "javascript"} code`, + }, + */ + [BLOCK_TYPES.LLM_INSTRUCTION]: { + label: "LLM Instruction", + icon: , + description: "Process data using LLM instructions", + defaultConfig: { + instruction: "", + inputVariable: "", + resultVariable: "", + }, + getSummary: (config) => config.instruction || "No instruction", + }, + [BLOCK_TYPES.WEB_SCRAPING]: { + label: "Web Scraping", + icon: , + description: "Scrape content from a webpage", + defaultConfig: { + url: "", + resultVariable: "", + }, + getSummary: (config) => config.url || "No URL specified", + }, + [BLOCK_TYPES.FINISH]: { + label: "Flow Complete", + icon: , + description: "End of agent flow", + getSummary: () => "Flow will end here", + defaultConfig: {}, + renderConfig: () => null, + }, +}; + +export default function BlockList({ + blocks, + updateBlockConfig, + removeBlock, + toggleBlockExpansion, + renderVariableSelect, + onDeleteVariable, + moveBlock, + refs, +}) { + const renderBlockConfig = (block) => { + const props = { + config: block.config, + onConfigChange: (config) => updateBlockConfig(block.id, config), + renderVariableSelect, + onDeleteVariable, + }; + + switch (block.type) { + case BLOCK_TYPES.FLOW_INFO: + return ; + case BLOCK_TYPES.START: + return ; + case BLOCK_TYPES.API_CALL: + return ; + case BLOCK_TYPES.WEBSITE: + return ; + case BLOCK_TYPES.FILE: + return ; + case BLOCK_TYPES.CODE: + return ; + case BLOCK_TYPES.LLM_INSTRUCTION: + return ; + case BLOCK_TYPES.WEB_SCRAPING: + return ; + case BLOCK_TYPES.FINISH: + return ; + default: + return
Configuration options coming soon...
; + } + }; + + return ( +
+ {blocks.map((block, index) => ( +
+
+
toggleBlockExpansion(block.id)} + className="w-full p-4 flex items-center justify-between hover:bg-theme-action-menu-item-hover transition-colors duration-300 group cursor-pointer" + > +
+
+ {React.cloneElement(BLOCK_INFO[block.type].icon, { + className: "w-4 h-4 text-white", + })} +
+
+ + {BLOCK_INFO[block.type].label} + + {!block.isExpanded && ( +

+ {BLOCK_INFO[block.type].getSummary(block.config)} +

+ )} +
+
+
+ {block.id !== "start" && + block.type !== BLOCK_TYPES.FINISH && + block.type !== BLOCK_TYPES.FLOW_INFO && ( +
+ {index > 1 && ( + + )} + {index < blocks.length - 2 && ( + + )} + +
+ )} +
+
+
+
+ {renderBlockConfig(block)} +
+
+
+ {index < blocks.length - 1 && ( +
+ + + +
+ )} +
+ ))} + +
+ ); +} + +export { BLOCK_TYPES, BLOCK_INFO }; diff --git a/frontend/src/pages/Admin/AgentBuilder/HeaderMenu/index.jsx b/frontend/src/pages/Admin/AgentBuilder/HeaderMenu/index.jsx new file mode 100644 index 00000000000..536d4af70ee --- /dev/null +++ b/frontend/src/pages/Admin/AgentBuilder/HeaderMenu/index.jsx @@ -0,0 +1,130 @@ +import { CaretDown, CaretUp, Plus } from "@phosphor-icons/react"; +import AnythingInfinityLogo from "@/media/logo/anything-llm-infinity.png"; +import { useState, useRef, useEffect } from "react"; +import { useNavigate, useParams } from "react-router-dom"; +import paths from "@/utils/paths"; +import { Link } from "react-router-dom"; + +export default function HeaderMenu({ + agentName, + availableFlows = [], + onNewFlow, + onSaveFlow, +}) { + const { flowId = null } = useParams(); + const [showDropdown, setShowDropdown] = useState(false); + const navigate = useNavigate(); + const dropdownRef = useRef(null); + const hasOtherFlows = + availableFlows.filter((flow) => flow.uuid !== flowId).length > 0; + + useEffect(() => { + function handleClickOutside(event) { + if (dropdownRef.current && !dropdownRef.current.contains(event.target)) { + setShowDropdown(false); + } + } + + document.addEventListener("mousedown", handleClickOutside); + return () => { + document.removeEventListener("mousedown", handleClickOutside); + }; + }, []); + + return ( +
+
+
+ +
+ + {showDropdown && ( +
+ {availableFlows + .filter((flow) => flow.uuid !== flowId) + .map((flow) => ( + + ))} +
+ )} +
+
+ +
+
+ + +
+ + view documentation → + +
+
+
+ ); +} diff --git a/frontend/src/pages/Admin/AgentBuilder/index.jsx b/frontend/src/pages/Admin/AgentBuilder/index.jsx new file mode 100644 index 00000000000..f658931dad5 --- /dev/null +++ b/frontend/src/pages/Admin/AgentBuilder/index.jsx @@ -0,0 +1,361 @@ +import React, { useState, useEffect, useRef } from "react"; +import { useNavigate, useParams } from "react-router-dom"; + +import BlockList, { BLOCK_TYPES, BLOCK_INFO } from "./BlockList"; +import AddBlockMenu from "./AddBlockMenu"; +import showToast from "@/utils/toast"; +import AgentFlows from "@/models/agentFlows"; +import { useTheme } from "@/hooks/useTheme"; +import HeaderMenu from "./HeaderMenu"; +import paths from "@/utils/paths"; + +const DEFAULT_BLOCKS = [ + { + id: "flow_info", + type: BLOCK_TYPES.FLOW_INFO, + config: { + name: "", + description: "", + }, + isExpanded: true, + }, + { + id: "start", + type: BLOCK_TYPES.START, + config: { + variables: [{ name: "", value: "" }], + }, + isExpanded: true, + }, + { + id: "finish", + type: BLOCK_TYPES.FINISH, + config: {}, + isExpanded: false, + }, +]; + +export default function AgentBuilder() { + const { flowId } = useParams(); + const { theme } = useTheme(); + const navigate = useNavigate(); + const [agentName, setAgentName] = useState(""); + const [_, setAgentDescription] = useState(""); + const [currentFlowUuid, setCurrentFlowUuid] = useState(null); + const [active, setActive] = useState(true); + const [blocks, setBlocks] = useState(DEFAULT_BLOCKS); + const [selectedBlock, setSelectedBlock] = useState("start"); + const [showBlockMenu, setShowBlockMenu] = useState(false); + const [showLoadMenu, setShowLoadMenu] = useState(false); + const [availableFlows, setAvailableFlows] = useState([]); + const [selectedFlowForDetails, setSelectedFlowForDetails] = useState(null); + const nameRef = useRef(null); + const descriptionRef = useRef(null); + + useEffect(() => { + loadAvailableFlows(); + }, []); + + useEffect(() => { + if (flowId) { + loadFlow(flowId); + } + }, [flowId]); + + useEffect(() => { + const flowInfoBlock = blocks.find( + (block) => block.type === BLOCK_TYPES.FLOW_INFO + ); + setAgentName(flowInfoBlock?.config?.name || ""); + }, [blocks]); + + const loadAvailableFlows = async () => { + try { + const { success, error, flows } = await AgentFlows.listFlows(); + if (!success) throw new Error(error); + setAvailableFlows(flows); + } catch (error) { + console.error(error); + showToast("Failed to load available flows", "error", { clear: true }); + } + }; + + const loadFlow = async (uuid) => { + try { + const { success, error, flow } = await AgentFlows.getFlow(uuid); + if (!success) throw new Error(error); + + // Convert steps to blocks with IDs, ensuring finish block is at the end + const flowBlocks = [ + { + id: "flow_info", + type: BLOCK_TYPES.FLOW_INFO, + config: { + name: flow.config.name, + description: flow.config.description, + }, + isExpanded: true, + }, + ...flow.config.steps.map((step, index) => ({ + id: index === 0 ? "start" : `block_${index}`, + type: step.type, + config: step.config, + isExpanded: true, + })), + ]; + + // Add finish block if not present + if (flowBlocks[flowBlocks.length - 1]?.type !== BLOCK_TYPES.FINISH) { + flowBlocks.push({ + id: "finish", + type: BLOCK_TYPES.FINISH, + config: {}, + isExpanded: false, + }); + } + + setAgentName(flow.config.name); + setAgentDescription(flow.config.description); + setActive(flow.config.active ?? true); + setCurrentFlowUuid(flow.uuid); + setBlocks(flowBlocks); + setShowLoadMenu(false); + } catch (error) { + console.error(error); + showToast("Failed to load flow", "error", { clear: true }); + } + }; + + const addBlock = (type) => { + const newBlock = { + id: `block_${blocks.length}`, + type, + config: { ...BLOCK_INFO[type].defaultConfig }, + isExpanded: true, + }; + // Insert the new block before the finish block + const newBlocks = [...blocks]; + newBlocks.splice(newBlocks.length - 1, 0, newBlock); + setBlocks(newBlocks); + setShowBlockMenu(false); + }; + + const updateBlockConfig = (blockId, config) => { + setBlocks( + blocks.map((block) => + block.id === blockId + ? { ...block, config: { ...block.config, ...config } } + : block + ) + ); + }; + + const removeBlock = (blockId) => { + if (blockId === "start") return; + setBlocks(blocks.filter((block) => block.id !== blockId)); + if (selectedBlock === blockId) { + setSelectedBlock("start"); + } + }; + + const saveFlow = async () => { + const flowInfoBlock = blocks.find( + (block) => block.type === BLOCK_TYPES.FLOW_INFO + ); + const name = flowInfoBlock?.config?.name; + const description = flowInfoBlock?.config?.description; + + if (!name?.trim() || !description?.trim()) { + // Make sure the flow info block is expanded first + if (!flowInfoBlock.isExpanded) { + setBlocks( + blocks.map((block) => + block.type === BLOCK_TYPES.FLOW_INFO + ? { ...block, isExpanded: true } + : block + ) + ); + // Small delay to allow expansion animation to complete + await new Promise((resolve) => setTimeout(resolve, 100)); + } + + if (!name?.trim()) { + nameRef.current?.focus(); + } else if (!description?.trim()) { + descriptionRef.current?.focus(); + } + showToast( + "Please provide both a name and description for your flow", + "error", + { + clear: true, + } + ); + return; + } + + const flowConfig = { + name, + description, + active, + steps: blocks + .filter( + (block) => + block.type !== BLOCK_TYPES.FINISH && + block.type !== BLOCK_TYPES.FLOW_INFO + ) + .map((block) => ({ + type: block.type, + config: block.config, + })), + }; + + try { + const { success, error, flow } = await AgentFlows.saveFlow( + name, + flowConfig, + currentFlowUuid + ); + if (!success) throw new Error(error); + + setCurrentFlowUuid(flow.uuid); + showToast("Agent flow saved successfully!", "success", { clear: true }); + await loadAvailableFlows(); + } catch (error) { + console.error("Save error details:", error); + showToast("Failed to save agent flow", "error", { clear: true }); + } + }; + + const toggleBlockExpansion = (blockId) => { + setBlocks( + blocks.map((block) => + block.id === blockId + ? { ...block, isExpanded: !block.isExpanded } + : block + ) + ); + }; + + // Get all available variables from the start block + const getAvailableVariables = () => { + const startBlock = blocks.find((b) => b.type === BLOCK_TYPES.START); + return startBlock?.config?.variables?.filter((v) => v.name) || []; + }; + + const renderVariableSelect = ( + value, + onChange, + placeholder = "Select variable" + ) => ( + + ); + + const deleteVariable = (variableName) => { + // Clean up references in other blocks + blocks.forEach((block) => { + if (block.type === BLOCK_TYPES.START) return; + + let configUpdated = false; + const newConfig = { ...block.config }; + + // Check and clean responseVariable/resultVariable + if (newConfig.responseVariable === variableName) { + newConfig.responseVariable = ""; + configUpdated = true; + } + if (newConfig.resultVariable === variableName) { + newConfig.resultVariable = ""; + configUpdated = true; + } + + if (configUpdated) { + updateBlockConfig(block.id, newConfig); + } + }); + }; + + // const runFlow = async (uuid) => { + // try { + // const { success, error, _results } = await AgentFlows.runFlow(uuid); + // if (!success) throw new Error(error); + + // showToast("Flow executed successfully!", "success", { clear: true }); + // } catch (error) { + // console.error(error); + // showToast("Failed to run agent flow", "error", { clear: true }); + // } + // }; + + const clearFlow = () => { + if (!!flowId) navigate(paths.agents.builder()); + setAgentName(""); + setAgentDescription(""); + setCurrentFlowUuid(null); + setActive(true); + setBlocks(DEFAULT_BLOCKS); + }; + + const moveBlock = (fromIndex, toIndex) => { + const newBlocks = [...blocks]; + const [movedBlock] = newBlocks.splice(fromIndex, 1); + newBlocks.splice(toIndex, 0, movedBlock); + setBlocks(newBlocks); + }; + + return ( +
+
+ +
+
+ + + +
+
+
+
+ ); +} diff --git a/frontend/src/pages/Admin/AgentBuilder/nodes/ApiCallNode/index.jsx b/frontend/src/pages/Admin/AgentBuilder/nodes/ApiCallNode/index.jsx new file mode 100644 index 00000000000..a1622909d28 --- /dev/null +++ b/frontend/src/pages/Admin/AgentBuilder/nodes/ApiCallNode/index.jsx @@ -0,0 +1,293 @@ +import React, { useRef, useState } from "react"; +import { Plus, X, CaretDown } from "@phosphor-icons/react"; + +export default function ApiCallNode({ + config, + onConfigChange, + renderVariableSelect, +}) { + const urlInputRef = useRef(null); + const [showVarMenu, setShowVarMenu] = useState(false); + const varButtonRef = useRef(null); + + const handleHeaderChange = (index, field, value) => { + const newHeaders = [...(config.headers || [])]; + newHeaders[index] = { ...newHeaders[index], [field]: value }; + onConfigChange({ headers: newHeaders }); + }; + + const addHeader = () => { + const newHeaders = [...(config.headers || []), { key: "", value: "" }]; + onConfigChange({ headers: newHeaders }); + }; + + const removeHeader = (index) => { + const newHeaders = [...(config.headers || [])].filter( + (_, i) => i !== index + ); + onConfigChange({ headers: newHeaders }); + }; + + const insertVariableAtCursor = (variableName) => { + if (!urlInputRef.current) return; + + const input = urlInputRef.current; + const start = input.selectionStart; + const end = input.selectionEnd; + const currentValue = config.url; + + const newValue = + currentValue.substring(0, start) + + "${" + + variableName + + "}" + + currentValue.substring(end); + + onConfigChange({ url: newValue }); + setShowVarMenu(false); + + // Set cursor position after the inserted variable + setTimeout(() => { + const newPosition = start + variableName.length + 3; // +3 for ${} + input.setSelectionRange(newPosition, newPosition); + input.focus(); + }, 0); + }; + + return ( +
+
+ +
+ onConfigChange({ url: e.target.value })} + className="flex-1 border-none bg-theme-settings-input-bg text-theme-text-primary placeholder:text-theme-settings-input-placeholder text-sm rounded-lg focus:outline-primary-button active:outline-primary-button outline-none p-2.5" + autoComplete="off" + spellCheck={false} + /> +
+ + {showVarMenu && ( +
+ {renderVariableSelect( + "", + insertVariableAtCursor, + "Select variable to insert", + true + )} +
+ )} +
+
+
+ +
+ + +
+ +
+
+ + +
+
+ {(config.headers || []).map((header, index) => ( +
+ + handleHeaderChange(index, "key", e.target.value) + } + className="flex-1 border-none bg-theme-settings-input-bg text-theme-text-primary placeholder:text-theme-settings-input-placeholder text-sm rounded-lg focus:outline-primary-button active:outline-primary-button outline-none p-2.5" + autoComplete="off" + spellCheck={false} + /> + + handleHeaderChange(index, "value", e.target.value) + } + className="flex-1 border-none bg-theme-settings-input-bg text-theme-text-primary placeholder:text-theme-settings-input-placeholder text-sm rounded-lg focus:outline-primary-button active:outline-primary-button outline-none p-2.5" + autoComplete="off" + spellCheck={false} + /> + +
+ ))} +
+
+ + {["POST", "PUT", "PATCH"].includes(config.method) && ( +
+ +
+ + {config.bodyType === "json" ? ( +