Compare commits

...

5 commits

Author SHA1 Message Date
Dietrich-at-Qvest
a46b7f0251
Merge b2205eb443 into 0e7fee41ca 2025-02-13 17:57:29 +05:30
timothycarambat
0e7fee41ca patch flow link 2025-02-12 23:25:37 -08:00
Sean Hatfield
e5f3fb0892
Agent flow builder ()
* wip agent builder

* refactor structure for agent builder

* improve ui for add block menu and sidebar

* lint

* node ui improvement

* handle deleting variable in all nodes

* add headers and body to apiCall node

* lint

* Agent flow builder backend ()

* wip agent builder backend

* save/load agent tasks

* lint

* refactor agent task to use uuids instead of names

* placeholder for run task

* update frontend sidebar + seperate backend to agent-tasks utils

* lint

* add deleting of agent tasks

* create AgentTasks class + wip load agent tasks into aibitat

* lint

* inject + call agent tasks

* wip call agent tasks

* add llm instruction + fix api calling blocks

* add ui + backend for editing/toggling agent tasks

* lint

* add back middlewares

* disable run task + add navigate to home on logo click

* implement normalizePath to prevent path traversal

* wip make api calling more consistent

* lint

* rename all references from task to flow

* patch load flow bug when on editing page

* remove unneeded files/comments

* lint

* fix delete endpoint + rename load flows

* add move block to ui + fix api-call backend + add telemetry

* lint

* add web scraping block

* only allow admin for agent builder

---------

Co-authored-by: timothycarambat <rambat1010@gmail.com>

* Move AgentFlowManager flows to static
simplify UI states
Handle LLM prompt flow when provided non-string

* delete/edit menu for agent flow panel + update flow icon

* lint

* fix open builder button hidden bug

* add tooltips to move up/down block buttons

* add tooltip to delete block

* truncate block description to fit on blocklist component

* light mode agent builder sidebar

* light mode api call block

* fix light mode styles for agent builder blocks

* agent flow fetch in UI

* sync delete flow

* agent flow ui/ux improvements

* remove unused AgentSidebar component

* comment out /run

* UI changes and updates for flow builder

* format flow panel info

* update link handling

* ui tweaks to header menu

* remove unused import

* update doc links
update block icons

* bump readme

* Patch code block header oddity
resolves 

* bump dev image

---------

Co-authored-by: Timothy Carambat <rambat1010@gmail.com>
2025-02-12 16:50:43 -08:00
timothycarambat
e2148d4803 Patch code block header oddity
resolves 
2025-02-12 16:30:38 -08:00
Timothy Carambat
cc3d619061
Add handling to reasoning models for Generic OpenAI connector ()
* Add handling to resoning models for Generic OpenAI connector
resolves 

* linting
2025-02-12 10:28:44 -08:00
47 changed files with 3265 additions and 69 deletions
.github/workflows
README.md
frontend
src
App.jsx
components
PrivateRoute
WorkspaceChat/ChatContainer/ChatHistory
HistoricalMessage
StatusResponse
index.css
media/logo
models
pages/Admin
AgentBuilder
AddBlockMenu
BlockList
HeaderMenu
index.jsx
nodes
ApiCallNode
CodeNode
FileNode
FinishNode
FlowInfoNode
LLMInstructionNode
StartNode
WebScrapingNode
WebsiteNode
Agents
AgentFlows
Imported
ImportedSkillConfig
SkillList
index.jsx
utils
tailwind.config.js
server

View file

@ -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/*'

View file

@ -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.

View file

@ -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={<AdminRoute Component={AdminAgents} />}
/>
<Route
path="/settings/agents/builder"
element={
<AdminRoute
Component={AgentBuilder}
hideUserMenu={true}
/>
}
/>
<Route
path="/settings/agents/builder/:flowId"
element={
<AdminRoute
Component={AgentBuilder}
hideUserMenu={true}
/>
}
/>
<Route
path="/settings/event-logs"
element={<AdminRoute Component={AdminLogs} />}

View file

@ -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 <FullScreenLoader />;
@ -94,9 +94,13 @@ export function AdminRoute({ Component }) {
const user = userFromStorage();
return isAuthd && (user?.role === "admin" || !multiUserMode) ? (
<UserMenu>
hideUserMenu ? (
<Component />
</UserMenu>
) : (
<UserMenu>
<Component />
</UserMenu>
)
) : (
<Navigate to={paths.home()} />
);

View file

@ -18,6 +18,10 @@ import {
} from "../ThoughtContainer";
const DOMPurify = createDOMPurify(window);
DOMPurify.setConfig({
ADD_ATTR: ["target", "rel"],
});
const HistoricalMessage = ({
uuid = v4(),
message,

View file

@ -71,7 +71,9 @@ export default function StatusResponse({
<div
key={`cot-list-${currentThought.uuid}`}
className={`mt-2 bg-theme-bg-chat-input backdrop-blur-sm rounded-lg overflow-hidden transition-all duration-300 border border-theme-sidebar-border ${
isExpanded ? "max-h-[300px] opacity-100" : "max-h-0 opacity-0"
isExpanded
? "max-h-[300px] overflow-y-auto opacity-100"
: "max-h-0 opacity-0"
}`}
>
<div className="p-2">

View file

@ -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 {

Binary file not shown.

After

(image error) Size: 552 B

View file

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

View file

@ -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 (
<div className="relative mt-4 w-[280px] mx-auto pb-[50%]" ref={menuRef}>
<button
onClick={() => setShowBlockMenu(!showBlockMenu)}
className="transition-all duration-300 w-full p-2.5 bg-theme-action-menu-bg hover:bg-theme-action-menu-item-hover border border-white/10 rounded-lg text-white flex items-center justify-center gap-2 text-sm font-medium"
>
<Plus className="w-4 h-4" />
Add Block
<CaretDown
className={`w-3.5 h-3.5 transition-transform duration-300 ${showBlockMenu ? "rotate-180" : ""}`}
/>
</button>
{showBlockMenu && (
<div className="absolute left-0 right-0 mt-2 bg-theme-action-menu-bg border border-white/10 rounded-lg shadow-lg overflow-hidden z-10 animate-fadeUpIn">
{Object.entries(BLOCK_INFO).map(
([type, info]) =>
type !== BLOCK_TYPES.START &&
type !== BLOCK_TYPES.FINISH &&
type !== BLOCK_TYPES.FLOW_INFO && (
<button
key={type}
onClick={() => {
addBlock(type);
setShowBlockMenu(false);
}}
className="w-full p-2.5 flex items-center gap-3 hover:bg-theme-action-menu-item-hover text-white transition-colors duration-300 group"
>
<div className="w-7 h-7 rounded-lg bg-white/10 flex items-center justify-center">
<div className="w-fit h-fit text-white">{info.icon}</div>
</div>
<div className="text-left flex-1">
<div className="text-sm font-medium">{info.label}</div>
<div className="text-xs text-white/60">
{info.description}
</div>
</div>
</button>
)
)}
</div>
)}
</div>
);
}

View file

@ -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: <Info className="w-5 h-5 text-theme-text-primary" />,
description: "Basic flow information",
defaultConfig: {
name: "",
description: "",
},
getSummary: (config) => config.name || "Untitled Flow",
},
[BLOCK_TYPES.START]: {
label: "Flow Variables",
icon: <BracketsCurly className="w-5 h-5 text-theme-text-primary" />,
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: <Globe className="w-5 h-5 text-theme-text-primary" />,
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: <Browser className="w-5 h-5 text-theme-text-primary" />,
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: <File className="w-5 h-5 text-theme-text-primary" />,
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: <Code className="w-5 h-5 text-theme-text-primary" />,
description: "Execute code snippets",
defaultConfig: {
language: "javascript",
code: "",
resultVariable: "",
},
getSummary: (config) => `Run ${config.language || "javascript"} code`,
},
*/
[BLOCK_TYPES.LLM_INSTRUCTION]: {
label: "LLM Instruction",
icon: <Brain className="w-5 h-5 text-theme-text-primary" />,
description: "Process data using LLM instructions",
defaultConfig: {
instruction: "",
inputVariable: "",
resultVariable: "",
},
getSummary: (config) => config.instruction || "No instruction",
},
[BLOCK_TYPES.WEB_SCRAPING]: {
label: "Web Scraping",
icon: <Browser className="w-5 h-5 text-theme-text-primary" />,
description: "Scrape content from a webpage",
defaultConfig: {
url: "",
resultVariable: "",
},
getSummary: (config) => config.url || "No URL specified",
},
[BLOCK_TYPES.FINISH]: {
label: "Flow Complete",
icon: <Flag className="w-4 h-4" />,
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 <FlowInfoNode {...props} ref={refs} />;
case BLOCK_TYPES.START:
return <StartNode {...props} />;
case BLOCK_TYPES.API_CALL:
return <ApiCallNode {...props} />;
case BLOCK_TYPES.WEBSITE:
return <WebsiteNode {...props} />;
case BLOCK_TYPES.FILE:
return <FileNode {...props} />;
case BLOCK_TYPES.CODE:
return <CodeNode {...props} />;
case BLOCK_TYPES.LLM_INSTRUCTION:
return <LLMInstructionNode {...props} />;
case BLOCK_TYPES.WEB_SCRAPING:
return <WebScrapingNode {...props} />;
case BLOCK_TYPES.FINISH:
return <FinishNode />;
default:
return <div>Configuration options coming soon...</div>;
}
};
return (
<div className="space-y-1">
{blocks.map((block, index) => (
<div key={block.id} className="flex flex-col">
<div
className={`bg-theme-action-menu-bg border border-white/10 rounded-lg overflow-hidden transition-all duration-300 ${
block.isExpanded ? "w-full" : "w-[280px] mx-auto"
}`}
>
<div
onClick={() => 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"
>
<div className="flex items-center gap-3">
<div className="w-7 h-7 rounded-lg bg-white/10 light:bg-white flex items-center justify-center">
{React.cloneElement(BLOCK_INFO[block.type].icon, {
className: "w-4 h-4 text-white",
})}
</div>
<div className="flex-1 text-left min-w-0 max-w-[115px]">
<span className="text-sm font-medium text-white block">
{BLOCK_INFO[block.type].label}
</span>
{!block.isExpanded && (
<p className="text-xs text-white/60 truncate">
{BLOCK_INFO[block.type].getSummary(block.config)}
</p>
)}
</div>
</div>
<div className="flex items-center">
{block.id !== "start" &&
block.type !== BLOCK_TYPES.FINISH &&
block.type !== BLOCK_TYPES.FLOW_INFO && (
<div className="flex items-center gap-1">
{index > 1 && (
<button
onClick={(e) => {
e.stopPropagation();
moveBlock(index, index - 1);
}}
className="p-1.5 rounded-lg bg-theme-bg-primary border border-white/5 text-white hover:bg-theme-action-menu-item-hover transition-colors duration-300"
data-tooltip-id="block-action"
data-tooltip-content="Move block up"
>
<CaretUp className="w-3.5 h-3.5" />
</button>
)}
{index < blocks.length - 2 && (
<button
onClick={(e) => {
e.stopPropagation();
moveBlock(index, index + 1);
}}
className="p-1.5 rounded-lg bg-theme-bg-primary border border-white/5 text-white hover:bg-theme-action-menu-item-hover transition-colors duration-300"
data-tooltip-id="block-action"
data-tooltip-content="Move block down"
>
<CaretDown className="w-3.5 h-3.5" />
</button>
)}
<button
onClick={(e) => {
e.stopPropagation();
removeBlock(block.id);
}}
className="p-1.5 rounded-lg bg-theme-bg-primary border border-white/5 text-red-400 hover:bg-red-500/10 hover:border-red-500/20 transition-colors duration-300"
data-tooltip-id="block-action"
data-tooltip-content="Delete block"
>
<X className="w-3.5 h-3.5" />
</button>
</div>
)}
</div>
</div>
<div
className={`overflow-hidden transition-all duration-300 ease-in-out ${
block.isExpanded
? "max-h-[1000px] opacity-100"
: "max-h-0 opacity-0"
}`}
>
<div className="border-t border-white/10 p-4 bg-theme-bg-secondary rounded-b-lg">
{renderBlockConfig(block)}
</div>
</div>
</div>
{index < blocks.length - 1 && (
<div className="flex justify-center my-1">
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className="text-white/40 light:invert"
>
<path
d="M12 4L12 20M12 20L6 14M12 20L18 14"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</div>
)}
</div>
))}
<Tooltip
id="block-action"
place="bottom"
delayShow={300}
className="tooltip !text-xs"
/>
</div>
);
}
export { BLOCK_TYPES, BLOCK_INFO };

View file

@ -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 (
<div className="absolute top-4 left-4 right-4">
<div className="flex justify-between items-center max-w-[1700px] mx-auto">
<div
className="flex items-center bg-theme-settings-input-bg rounded-md border border-white/10 pointer-events-auto"
ref={dropdownRef}
>
<button
onClick={() => navigate(paths.settings.agentSkills())}
className="border-y-none border-l-none flex items-center gap-x-2 px-4 py-2 border-r border-white/10 hover:bg-theme-action-menu-bg transition-colors duration-300"
>
<img
src={AnythingInfinityLogo}
alt="logo"
className="w-[20px] light:invert"
/>
<span className="text-theme-text-primary text-sm uppercase tracking-widest">
Builder
</span>
</button>
<div className="relative">
<button
disabled={!hasOtherFlows}
className="border-none flex items-center justify-between gap-x-1 text-theme-text-primary text-sm px-4 py-2 enabled:hover:bg-theme-action-menu-bg transition-colors duration-300 min-w-[200px] max-w-[300px]"
onClick={() => {
if (!agentName && !hasOtherFlows) {
const agentNameInput = document.getElementById(
"agent-flow-name-input"
);
if (agentNameInput) agentNameInput.focus();
return;
}
setShowDropdown(!showDropdown);
}}
>
<span
className={`text-sm font-medium truncate ${!!agentName ? "text-theme-text-primary " : "text-theme-text-secondary"}`}
>
{agentName || "Untitled Flow"}
</span>
{hasOtherFlows && (
<div className="flex flex-col ml-2 shrink-0">
<CaretUp size={10} />
<CaretDown size={10} />
</div>
)}
</button>
{showDropdown && (
<div className="absolute top-full left-0 mt-1 w-full min-w-[200px] max-w-[350px] bg-theme-settings-input-bg border border-white/10 rounded-md shadow-lg z-50 animate-fadeUpIn">
{availableFlows
.filter((flow) => flow.uuid !== flowId)
.map((flow) => (
<button
key={flow?.uuid || Math.random()}
onClick={() => {
navigate(paths.agents.editAgent(flow.uuid));
setShowDropdown(false);
}}
className="border-none w-full text-left px-2 py-1 text-sm text-theme-text-primary hover:bg-theme-action-menu-bg transition-colors duration-300"
>
<span className="block truncate">
{flow?.name || "Untitled Flow"}
</span>
</button>
))}
</div>
)}
</div>
</div>
<div className="flex flex-col gap-y-1 items-end">
<div className="flex items-center gap-x-[15px]">
<button
onClick={onNewFlow}
className="flex items-center gap-x-2 text-theme-text-primary text-sm font-medium px-3 py-2 rounded-lg border border-white bg-theme-settings-input-bg hover:bg-theme-action-menu-bg transition-colors duration-300"
>
<Plus className="w-4 h-4" />
New Flow
</button>
<button
onClick={onSaveFlow}
className="border-none bg-primary-button hover:opacity-80 text-black px-3 py-2 rounded-lg text-sm font-medium transition-all duration-300 flex items-center justify-center gap-2"
>
Save
</button>
</div>
<Link
to="https://docs.anythingllm.com/agent-flows/overview"
className="text-theme-text-secondary text-sm hover:underline hover:text-cta-button flex items-center gap-x-1 w-fit float-right"
>
view documentation &rarr;
</Link>
</div>
</div>
</div>
);
}

View file

@ -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"
) => (
<select
value={value || ""}
onChange={(e) => onChange(e.target.value)}
className="w-full border-none bg-theme-settings-input-bg text-theme-text-primary text-sm rounded-lg focus:outline-primary-button active:outline-primary-button outline-none p-2.5"
>
<option value="" className="bg-theme-bg-primary">
{placeholder}
</option>
{getAvailableVariables().map((v) => (
<option key={v.name} value={v.name} className="bg-theme-bg-primary">
{v.name}
</option>
))}
</select>
);
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 (
<div
style={{
backgroundImage:
theme === "light"
? "radial-gradient(rgba(0, 0, 0, 0.1) 1px, transparent 0)"
: "radial-gradient(rgba(255, 255, 255, 0.1) 1px, transparent 0)",
backgroundSize: "15px 15px",
backgroundPosition: "-7.5px -7.5px",
}}
className="w-full h-screen flex bg-theme-bg-primary"
>
<div className="w-full flex flex-col">
<HeaderMenu
agentName={agentName}
availableFlows={availableFlows}
onNewFlow={clearFlow}
onSaveFlow={saveFlow}
/>
<div className="flex-1 p-6 overflow-y-auto">
<div className="max-w-xl mx-auto mt-14">
<BlockList
blocks={blocks}
updateBlockConfig={updateBlockConfig}
removeBlock={removeBlock}
toggleBlockExpansion={toggleBlockExpansion}
renderVariableSelect={renderVariableSelect}
onDeleteVariable={deleteVariable}
moveBlock={moveBlock}
refs={{ nameRef, descriptionRef }}
/>
<AddBlockMenu
showBlockMenu={showBlockMenu}
setShowBlockMenu={setShowBlockMenu}
addBlock={addBlock}
/>
</div>
</div>
</div>
</div>
);
}

View file

@ -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 (
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-theme-text-primary mb-2">
URL
</label>
<div className="flex gap-2">
<input
ref={urlInputRef}
type="text"
placeholder="https://api.example.com/endpoint"
value={config.url}
onChange={(e) => 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}
/>
<div className="relative">
<button
ref={varButtonRef}
onClick={() => setShowVarMenu(!showVarMenu)}
className="h-full px-3 rounded-lg border-none bg-theme-settings-input-bg text-theme-text-primary hover:bg-theme-action-menu-item-hover transition-colors duration-300 flex items-center gap-1"
title="Insert variable"
>
<Plus className="w-4 h-4" />
<CaretDown className="w-3 h-3" />
</button>
{showVarMenu && (
<div className="absolute right-0 top-[calc(100%+4px)] w-48 bg-theme-settings-input-bg border-none rounded-lg shadow-lg z-10">
{renderVariableSelect(
"",
insertVariableAtCursor,
"Select variable to insert",
true
)}
</div>
)}
</div>
</div>
</div>
<div>
<label className="block text-sm font-medium text-theme-text-primary mb-2">
Method
</label>
<select
value={config.method}
onChange={(e) => onConfigChange({ method: e.target.value })}
className="w-full border-none bg-theme-settings-input-bg text-theme-text-primary text-sm rounded-lg focus:outline-primary-button active:outline-primary-button outline-none p-2.5"
>
{["GET", "POST", "DELETE"].map((method) => (
<option
key={method}
value={method}
className="bg-theme-settings-input-bg"
>
{method}
</option>
))}
</select>
</div>
<div>
<div className="flex items-center justify-between mb-2">
<label className="text-sm font-medium text-theme-text-primary">
Headers
</label>
<button
onClick={addHeader}
className="p-1.5 rounded-lg border-none bg-theme-settings-input-bg text-theme-text-primary hover:bg-theme-action-menu-item-hover transition-colors duration-300"
title="Add header"
>
<Plus className="w-3.5 h-3.5" />
</button>
</div>
<div className="space-y-2">
{(config.headers || []).map((header, index) => (
<div key={index} className="flex gap-2">
<input
type="text"
placeholder="Header name"
value={header.key}
onChange={(e) =>
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}
/>
<input
type="text"
placeholder="Value"
value={header.value}
onChange={(e) =>
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}
/>
<button
onClick={() => removeHeader(index)}
className="p-2.5 rounded-lg border-none bg-theme-settings-input-bg text-theme-text-primary hover:text-red-500 hover:border-red-500/20 hover:bg-red-500/10 transition-colors duration-300"
title="Remove header"
>
<X className="w-4 h-4" />
</button>
</div>
))}
</div>
</div>
{["POST", "PUT", "PATCH"].includes(config.method) && (
<div>
<label className="block text-sm font-medium text-theme-text-primary mb-2">
Request Body
</label>
<div className="space-y-2">
<select
value={config.bodyType || "json"}
onChange={(e) => onConfigChange({ bodyType: e.target.value })}
className="w-full p-2.5 text-sm rounded-lg bg-theme-bg-primary border border-white/5 text-theme-text-primary focus:border-primary-button focus:ring-1 focus:ring-primary-button outline-none light:bg-theme-settings-input-bg light:border-black/10"
>
<option
value="json"
className="bg-theme-bg-primary light:bg-theme-settings-input-bg"
>
JSON
</option>
<option
value="text"
className="bg-theme-bg-primary light:bg-theme-settings-input-bg"
>
Raw Text
</option>
<option
value="form"
className="bg-theme-bg-primary light:bg-theme-settings-input-bg"
>
Form Data
</option>
</select>
{config.bodyType === "json" ? (
<textarea
placeholder='{"key": "value"}'
value={config.body}
onChange={(e) => onConfigChange({ body: e.target.value })}
className="w-full p-2.5 text-sm rounded-lg bg-theme-bg-primary border border-white/5 text-theme-text-primary placeholder:text-theme-text-secondary/20 focus:border-primary-button focus:ring-1 focus:ring-primary-button outline-none light:bg-theme-settings-input-bg light:border-black/10 font-mono"
rows={4}
autoComplete="off"
spellCheck={false}
/>
) : config.bodyType === "form" ? (
<div className="space-y-2">
{(config.formData || []).map((item, index) => (
<div key={index} className="flex gap-2">
<input
type="text"
placeholder="Key"
value={item.key}
onChange={(e) => {
const newFormData = [...(config.formData || [])];
newFormData[index] = { ...item, key: e.target.value };
onConfigChange({ formData: newFormData });
}}
className="flex-1 p-2.5 text-sm rounded-lg bg-theme-bg-primary border border-white/5 text-theme-text-primary placeholder:text-theme-text-secondary/20 focus:border-primary-button focus:ring-1 focus:ring-primary-button outline-none light:bg-theme-settings-input-bg light:border-black/10"
autoComplete="off"
spellCheck={false}
/>
<input
type="text"
placeholder="Value"
value={item.value}
onChange={(e) => {
const newFormData = [...(config.formData || [])];
newFormData[index] = { ...item, value: e.target.value };
onConfigChange({ formData: newFormData });
}}
className="flex-1 p-2.5 text-sm rounded-lg bg-theme-bg-primary border border-white/5 text-theme-text-primary placeholder:text-theme-text-secondary/20 focus:border-primary-button focus:ring-1 focus:ring-primary-button outline-none light:bg-theme-settings-input-bg light:border-black/10"
autoComplete="off"
spellCheck={false}
/>
<button
onClick={() => {
const newFormData = [...(config.formData || [])].filter(
(_, i) => i !== index
);
onConfigChange({ formData: newFormData });
}}
className="p-2.5 rounded-lg bg-theme-bg-primary border border-white/5 text-theme-text-primary hover:text-red-500 hover:border-red-500/20 hover:bg-red-500/10 transition-colors duration-300 light:bg-theme-settings-input-bg light:border-black/10"
title="Remove field"
>
<X className="w-4 h-4" />
</button>
</div>
))}
<button
onClick={() => {
const newFormData = [
...(config.formData || []),
{ key: "", value: "" },
];
onConfigChange({ formData: newFormData });
}}
className="w-full p-2.5 rounded-lg border-none bg-theme-settings-input-bg text-theme-text-primary hover:bg-theme-action-menu-item-hover transition-colors duration-300 text-sm"
>
Add Form Field
</button>
</div>
) : (
<textarea
placeholder="Raw request body..."
value={config.body}
onChange={(e) => onConfigChange({ body: e.target.value })}
className="w-full 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"
rows={4}
autoComplete="off"
spellCheck={false}
/>
)}
</div>
</div>
)}
<div>
<label className="block text-sm font-medium text-theme-text-primary mb-2">
Store Response In
</label>
{renderVariableSelect(
config.responseVariable,
(value) => onConfigChange({ responseVariable: value }),
"Select or create variable"
)}
</div>
</div>
);
}

View file

@ -0,0 +1,56 @@
import React from "react";
export default function CodeNode({
config,
onConfigChange,
renderVariableSelect,
}) {
return (
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-white mb-2">
Language
</label>
<select
value={config.language}
onChange={(e) => onConfigChange({ language: e.target.value })}
className="w-full p-2.5 text-sm rounded-lg bg-theme-bg-primary border border-white/5 text-white focus:border-primary-button focus:ring-1 focus:ring-primary-button outline-none"
>
<option value="javascript" className="bg-theme-bg-primary">
JavaScript
</option>
<option value="python" className="bg-theme-bg-primary">
Python
</option>
<option value="shell" className="bg-theme-bg-primary">
Shell
</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-white mb-2">
Code
</label>
<textarea
placeholder="Enter code..."
value={config.code}
onChange={(e) => onConfigChange({ code: e.target.value })}
className="w-full p-2.5 text-sm rounded-lg bg-theme-bg-primary border border-white/5 text-white placeholder:text-white/20 focus:border-primary-button focus:ring-1 focus:ring-primary-button outline-none font-mono"
rows={5}
autoComplete="off"
spellCheck={false}
/>
</div>
<div>
<label className="block text-sm font-medium text-white mb-2">
Store Result In
</label>
{renderVariableSelect(
config.resultVariable,
(value) => onConfigChange({ resultVariable: value }),
"Select or create variable"
)}
</div>
</div>
);
}

View file

@ -0,0 +1,72 @@
import React from "react";
export default function FileNode({
config,
onConfigChange,
renderVariableSelect,
}) {
return (
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-white mb-2">
Operation
</label>
<select
value={config.operation}
onChange={(e) => onConfigChange({ operation: e.target.value })}
className="w-full p-2.5 text-sm rounded-lg bg-theme-bg-primary border border-white/5 text-white focus:border-primary-button focus:ring-1 focus:ring-primary-button outline-none"
>
<option value="read" className="bg-theme-bg-primary">
Read File
</option>
<option value="write" className="bg-theme-bg-primary">
Write File
</option>
<option value="append" className="bg-theme-bg-primary">
Append to File
</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-white mb-2">
File Path
</label>
<input
type="text"
placeholder="/path/to/file"
value={config.path}
onChange={(e) => onConfigChange({ path: e.target.value })}
className="w-full p-2.5 text-sm rounded-lg bg-theme-bg-primary border border-white/5 text-white placeholder:text-white/20 focus:border-primary-button focus:ring-1 focus:ring-primary-button outline-none"
autoComplete="off"
spellCheck={false}
/>
</div>
{config.operation !== "read" && (
<div>
<label className="block text-sm font-medium text-white mb-2">
Content
</label>
<textarea
placeholder="File content..."
value={config.content}
onChange={(e) => onConfigChange({ content: e.target.value })}
className="w-full p-2.5 text-sm rounded-lg bg-theme-bg-primary border border-white/5 text-white placeholder:text-white/20 focus:border-primary-button focus:ring-1 focus:ring-primary-button outline-none"
rows={3}
autoComplete="off"
spellCheck={false}
/>
</div>
)}
<div>
<label className="block text-sm font-medium text-white mb-2">
Store Result In
</label>
{renderVariableSelect(
config.resultVariable,
(value) => onConfigChange({ resultVariable: value }),
"Select or create variable"
)}
</div>
</div>
);
}

View file

@ -0,0 +1,10 @@
import React from "react";
export default function FinishNode() {
return (
<div className="text-sm text-white/60">
This is the end of your agent flow. All steps above will be executed in
sequence.
</div>
);
}

View file

@ -0,0 +1,65 @@
import React, { forwardRef } from "react";
const FlowInfoNode = forwardRef(({ config, onConfigChange }, refs) => {
return (
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-theme-text-primary mb-2">
Flow Name
</label>
<div className="flex flex-col text-xs text-theme-text-secondary mt-2 mb-3">
<p className="">
It is important to give your flow a name that an LLM can easily
understand.
</p>
<p>"SendMessageToDiscord", "CheckStockPrice", "CheckWeather"</p>
</div>
<input
id="agent-flow-name-input"
ref={refs?.nameRef}
type="text"
placeholder="Enter flow name"
value={config?.name || ""}
onChange={(e) =>
onConfigChange({
...config,
name: e.target.value,
})
}
className="w-full 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}
/>
</div>
<div>
<label className="block text-sm font-medium text-theme-text-primary mb-2">
Description
</label>
<div className="flex flex-col text-xs text-theme-text-secondary mt-2 mb-3">
<p className="">
It is equally important to give your flow a description that an LLM
can easily understand. Be sure to include the purpose of the flow,
the context it will be used in, and any other relevant information.
</p>
</div>
<textarea
ref={refs?.descriptionRef}
value={config?.description || ""}
onChange={(e) =>
onConfigChange({
...config,
description: e.target.value,
})
}
className="w-full 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"
rows={3}
placeholder="Enter flow description"
/>
</div>
</div>
);
});
FlowInfoNode.displayName = "FlowInfoNode";
export default FlowInfoNode;

View file

@ -0,0 +1,52 @@
import React from "react";
export default function LLMInstructionNode({
config,
onConfigChange,
renderVariableSelect,
}) {
return (
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-theme-text-primary mb-2">
Input Variable
</label>
{renderVariableSelect(
config.inputVariable,
(value) => onConfigChange({ ...config, inputVariable: value }),
"Select input variable"
)}
</div>
<div>
<label className="block text-sm font-medium text-theme-text-primary mb-2">
Instruction
</label>
<textarea
value={config?.instruction || ""}
onChange={(e) =>
onConfigChange({
...config,
instruction: e.target.value,
})
}
className="w-full 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"
rows={3}
placeholder="Enter instructions for the LLM..."
/>
</div>
<div>
<label className="block text-sm font-medium text-theme-text-primary mb-2">
Result Variable
</label>
{renderVariableSelect(
config.resultVariable,
(value) => onConfigChange({ ...config, resultVariable: value }),
"Select or create variable",
true
)}
</div>
</div>
);
}

View file

@ -0,0 +1,72 @@
import React from "react";
import { Plus, X } from "@phosphor-icons/react";
export default function StartNode({
config,
onConfigChange,
onDeleteVariable,
}) {
const handleDeleteVariable = (index, variableName) => {
// First clean up references, then delete the variable
onDeleteVariable(variableName);
const newVars = config.variables.filter((_, i) => i !== index);
onConfigChange({ variables: newVars });
};
return (
<div className="space-y-4">
<h3 className="text-sm font-medium text-theme-text-primary">Variables</h3>
{config.variables.map((variable, index) => (
<div key={index} className="flex gap-2">
<input
type="text"
placeholder="Variable name"
value={variable.name}
onChange={(e) => {
const newVars = [...config.variables];
newVars[index].name = e.target.value;
onConfigChange({ variables: newVars });
}}
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}
/>
<input
type="text"
placeholder="Initial value"
value={variable.value}
onChange={(e) => {
const newVars = [...config.variables];
newVars[index].value = e.target.value;
onConfigChange({ variables: newVars });
}}
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}
/>
{config.variables.length > 1 && (
<button
onClick={() => handleDeleteVariable(index, variable.name)}
className="p-2.5 rounded-lg border-none bg-theme-settings-input-bg text-theme-text-primary hover:text-red-500 hover:border-red-500/20 hover:bg-red-500/10 transition-colors duration-300"
title="Delete variable"
>
<X className="w-4 h-4" />
</button>
)}
{index === config.variables.length - 1 && (
<button
onClick={() => {
const newVars = [...config.variables, { name: "", value: "" }];
onConfigChange({ variables: newVars });
}}
className="p-2.5 rounded-lg border-none bg-theme-settings-input-bg text-theme-text-primary hover:bg-theme-action-menu-item-hover transition-colors duration-300"
title="Add variable"
>
<Plus className="w-4 h-4" />
</button>
)}
</div>
))}
</div>
);
}

View file

@ -0,0 +1,41 @@
import React from "react";
export default function WebScrapingNode({
config,
onConfigChange,
renderVariableSelect,
}) {
return (
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-theme-text-primary mb-2">
URL to Scrape
</label>
<input
type="url"
value={config?.url || ""}
onChange={(e) =>
onConfigChange({
...config,
url: e.target.value,
})
}
className="w-full 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"
placeholder="https://example.com"
/>
</div>
<div>
<label className="block text-sm font-medium text-theme-text-primary mb-2">
Result Variable
</label>
{renderVariableSelect(
config.resultVariable,
(value) => onConfigChange({ ...config, resultVariable: value }),
"Select or create variable",
true
)}
</div>
</div>
);
}

View file

@ -0,0 +1,68 @@
import React from "react";
export default function WebsiteNode({
config,
onConfigChange,
renderVariableSelect,
}) {
return (
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-white mb-2">URL</label>
<input
type="text"
placeholder="https://example.com"
value={config.url}
onChange={(e) => onConfigChange({ url: e.target.value })}
className="w-full p-2.5 text-sm rounded-lg bg-theme-bg-primary border border-white/5 text-white placeholder:text-white/20 focus:border-primary-button focus:ring-1 focus:ring-primary-button outline-none"
autoComplete="off"
spellCheck={false}
/>
</div>
<div>
<label className="block text-sm font-medium text-white mb-2">
Action
</label>
<select
value={config.action}
onChange={(e) => onConfigChange({ action: e.target.value })}
className="w-full p-2.5 text-sm rounded-lg bg-theme-bg-primary border border-white/5 text-white focus:border-primary-button focus:ring-1 focus:ring-primary-button outline-none"
>
<option value="read" className="bg-theme-bg-primary">
Read Content
</option>
<option value="click" className="bg-theme-bg-primary">
Click Element
</option>
<option value="type" className="bg-theme-bg-primary">
Type Text
</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-white mb-2">
CSS Selector
</label>
<input
type="text"
placeholder="#element-id or .class-name"
value={config.selector}
onChange={(e) => onConfigChange({ selector: e.target.value })}
className="w-full p-2.5 text-sm rounded-lg bg-theme-bg-primary border border-white/5 text-white placeholder:text-white/20 focus:border-primary-button focus:ring-1 focus:ring-primary-button outline-none"
autoComplete="off"
spellCheck={false}
/>
</div>
<div>
<label className="block text-sm font-medium text-white mb-2">
Store Result In
</label>
{renderVariableSelect(
config.resultVariable,
(value) => onConfigChange({ resultVariable: value }),
"Select or create variable"
)}
</div>
</div>
);
}

View file

@ -0,0 +1,124 @@
import React, { useState, useEffect, useRef } from "react";
import AgentFlows from "@/models/agentFlows";
import showToast from "@/utils/toast";
import { FlowArrow, Gear } from "@phosphor-icons/react";
import { useNavigate } from "react-router-dom";
import paths from "@/utils/paths";
function ManageFlowMenu({ flow, onDelete }) {
const [open, setOpen] = useState(false);
const menuRef = useRef(null);
const navigate = useNavigate();
async function deleteFlow() {
if (
!window.confirm(
"Are you sure you want to delete this flow? This action cannot be undone."
)
)
return;
const { success, error } = await AgentFlows.deleteFlow(flow.uuid);
if (success) {
showToast("Flow deleted successfully.", "success");
onDelete(flow.uuid);
} else {
showToast(error || "Failed to delete flow.", "error");
}
}
useEffect(() => {
const handleClickOutside = (event) => {
if (menuRef.current && !menuRef.current.contains(event.target)) {
setOpen(false);
}
};
document.addEventListener("mousedown", handleClickOutside);
return () => {
document.removeEventListener("mousedown", handleClickOutside);
};
}, []);
return (
<div className="relative" ref={menuRef}>
<button
type="button"
onClick={() => setOpen(!open)}
className="p-1.5 rounded-lg text-white hover:bg-theme-action-menu-item-hover transition-colors duration-300"
>
<Gear className="h-5 w-5" weight="bold" />
</button>
{open && (
<div className="absolute w-[100px] -top-1 left-7 mt-1 border-[1.5px] border-white/40 rounded-lg bg-theme-action-menu-bg flex flex-col shadow-[0_4px_14px_rgba(0,0,0,0.25)] text-white z-99 md:z-10">
<button
type="button"
onClick={() => navigate(paths.agents.editAgent(flow.uuid))}
className="border-none flex items-center rounded-lg gap-x-2 hover:bg-theme-action-menu-item-hover py-1.5 px-2 transition-colors duration-200 w-full text-left"
>
<span className="text-sm">Edit Flow</span>
</button>
<button
type="button"
onClick={deleteFlow}
className="border-none flex items-center rounded-lg gap-x-2 hover:bg-theme-action-menu-item-hover py-1.5 px-2 transition-colors duration-200 w-full text-left"
>
<span className="text-sm">Delete Flow</span>
</button>
</div>
)}
</div>
);
}
export default function FlowPanel({ flow, toggleFlow, onDelete }) {
const [isActive, setIsActive] = useState(flow.active);
useEffect(() => {
setIsActive(flow.active);
}, [flow.uuid, flow.active]);
const handleToggle = async () => {
try {
const { success, error } = await AgentFlows.toggleFlow(
flow.uuid,
!isActive
);
if (!success) throw new Error(error);
setIsActive(!isActive);
toggleFlow(flow.uuid);
showToast("Flow status updated successfully", "success", { clear: true });
} catch (error) {
console.error("Failed to toggle flow:", error);
showToast("Failed to toggle flow", "error", { clear: true });
}
};
return (
<>
<div className="p-2">
<div className="flex flex-col gap-y-[18px] max-w-[500px]">
<div className="flex items-center gap-x-2">
<FlowArrow size={24} weight="bold" className="text-white" />
<label htmlFor="name" className="text-white text-md font-bold">
{flow.name}
</label>
<label className="border-none relative inline-flex items-center ml-auto cursor-pointer">
<input
type="checkbox"
className="peer sr-only"
checked={isActive}
onChange={handleToggle}
/>
<div className="peer-disabled:opacity-50 pointer-events-none peer h-6 w-11 rounded-full bg-[#CFCFD0] after:absolute after:left-[2px] after:top-[2px] after:h-5 after:w-5 after:rounded-full after:shadow-xl after:border-none after:bg-white after:box-shadow-md after:transition-all after:content-[''] peer-checked:bg-[#32D583] peer-checked:after:translate-x-full peer-checked:after:border-white peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-transparent"></div>
<span className="ml-3 text-sm font-medium"></span>
</label>
<ManageFlowMenu flow={flow} onDelete={onDelete} />
</div>
<p className="whitespace-pre-wrap text-white text-opacity-60 text-xs font-medium py-1.5">
{flow.description || "No description provided"}
</p>
</div>
</div>
</>
);
}

View file

@ -0,0 +1,57 @@
import React from "react";
import { CaretRight } from "@phosphor-icons/react";
export default function AgentFlowsList({
flows = [],
selectedFlow,
handleClick,
}) {
if (flows.length === 0) {
return (
<div className="text-theme-text-secondary text-center text-xs flex flex-col gap-y-2">
<p>No agent flows found</p>
<a
href="https://docs.anythingllm.com/agent-flows/getting-started"
target="_blank"
className="text-theme-text-secondary underline hover:text-cta-button"
>
Learn more about Agent Flows.
</a>
</div>
);
}
return (
<div className="bg-theme-bg-secondary text-white rounded-xl min-w-[360px] w-fit">
{flows.map((flow, index) => (
<div
key={flow.uuid}
className={`py-3 px-4 flex items-center justify-between ${
index === 0 ? "rounded-t-xl" : ""
} ${
index === flows.length - 1
? "rounded-b-xl"
: "border-b border-white/10"
} cursor-pointer transition-all duration-300 hover:bg-theme-bg-primary ${
selectedFlow?.uuid === flow.uuid
? "bg-white/10 light:bg-theme-bg-sidebar"
: ""
}`}
onClick={() => handleClick?.(flow)}
>
<div className="text-sm font-light">{flow.name}</div>
<div className="flex items-center gap-x-2">
<div className="text-sm text-theme-text-secondary font-medium">
{flow.active ? "On" : "Off"}
</div>
<CaretRight
size={14}
weight="bold"
className="text-theme-text-secondary"
/>
</div>
</div>
))}
</div>
);
}

View file

@ -231,9 +231,9 @@ function ManageSkillMenu({ config, setImportedSkills }) {
<button
type="button"
onClick={() => setOpen(!open)}
className={`border-none transition duration-200 hover:rotate-90 outline-none ring-none ${open ? "rotate-90" : ""}`}
className="p-1.5 rounded-lg text-white hover:bg-theme-action-menu-item-hover transition-colors duration-300"
>
<Gear size={24} weight="bold" />
<Gear className="h-5 w-5" weight="bold" />
</button>
{open && (
<div className="absolute w-[100px] -top-1 left-7 mt-1 border-[1.5px] border-white/40 rounded-lg bg-theme-action-menu-bg flex flex-col shadow-[0_4px_14px_rgba(0,0,0,0.25)] text-white z-99 md:z-10">

View file

@ -16,7 +16,7 @@ export default function ImportedSkillList({
<a
href="https://docs.anythingllm.com/agent/custom/developer-guide"
target="_blank"
className="text-theme-text-secondary light:underline hover:underline"
className="text-theme-text-secondary underline hover:text-cta-button"
>
AnythingLLM Agent Docs
</a>

View file

@ -4,7 +4,15 @@ import { isMobile } from "react-device-detect";
import Admin from "@/models/admin";
import System from "@/models/system";
import showToast from "@/utils/toast";
import { CaretLeft, CaretRight, Plug, Robot } from "@phosphor-icons/react";
import {
CaretLeft,
CaretRight,
Plug,
Robot,
Hammer,
FlowArrow,
PlusCircle,
} from "@phosphor-icons/react";
import ContextualSaveBar from "@/components/ContextualSaveBar";
import { castToType } from "@/utils/types";
import { FullScreenLoader } from "@/components/Preloader";
@ -13,6 +21,11 @@ import { DefaultBadge } from "./Badges/default";
import ImportedSkillList from "./Imported/SkillList";
import ImportedSkillConfig from "./Imported/ImportedSkillConfig";
import { Tooltip } from "react-tooltip";
import AgentFlowsList from "./AgentFlows";
import FlowPanel from "./AgentFlows/FlowPanel";
import { Link } from "react-router-dom";
import paths from "@/utils/paths";
import AgentFlows from "@/models/agentFlows";
export default function AdminAgents() {
const formEl = useRef(null);
@ -26,6 +39,10 @@ export default function AdminAgents() {
const [importedSkills, setImportedSkills] = useState([]);
const [disabledAgentSkills, setDisabledAgentSkills] = useState([]);
const [agentFlows, setAgentFlows] = useState([]);
const [selectedFlow, setSelectedFlow] = useState(null);
const [activeFlowIds, setActiveFlowIds] = useState([]);
// Alert user if they try to leave the page with unsaved changes
useEffect(() => {
const handleBeforeUnload = (event) => {
@ -47,13 +64,17 @@ export default function AdminAgents() {
"disabled_agent_skills",
"default_agent_skills",
"imported_agent_skills",
"active_agent_flows",
]);
const { flows = [] } = await AgentFlows.listFlows();
setSettings({ ..._settings, preferences: _preferences.settings } ?? {});
setAgentSkills(_preferences.settings?.default_agent_skills ?? []);
setDisabledAgentSkills(
_preferences.settings?.disabled_agent_skills ?? []
);
setImportedSkills(_preferences.settings?.imported_agent_skills ?? []);
setActiveFlowIds(_preferences.settings?.active_agent_flows ?? []);
setAgentFlows(flows);
setLoading(false);
}
fetchSettings();
@ -79,6 +100,15 @@ export default function AdminAgents() {
});
};
const toggleFlow = (flowId) => {
setActiveFlowIds((prev) => {
const updatedFlows = prev.includes(flowId)
? prev.filter((id) => id !== flowId)
: [...prev, flowId];
return updatedFlows;
});
};
const handleSubmit = async (e) => {
e.preventDefault();
const data = {
@ -129,10 +159,30 @@ export default function AdminAgents() {
setHasChanges(false);
};
const SelectedSkillComponent = selectedSkill.imported
? ImportedSkillConfig
: configurableSkills[selectedSkill]?.component ||
defaultSkills[selectedSkill]?.component;
const SelectedSkillComponent = selectedFlow
? FlowPanel
: selectedSkill?.imported
? ImportedSkillConfig
: configurableSkills[selectedSkill]?.component ||
defaultSkills[selectedSkill]?.component;
// Update the click handlers to clear the other selection
const handleSkillClick = (skill) => {
setSelectedFlow(null);
setSelectedSkill(skill);
if (isMobile) setShowSkillModal(true);
};
const handleFlowClick = (flow) => {
setSelectedSkill(null);
setSelectedFlow(flow);
};
const handleFlowDelete = (flowId) => {
setSelectedFlow(null);
setActiveFlowIds((prev) => prev.filter((id) => id !== flowId));
setAgentFlows((prev) => prev.filter((flow) => flow.uuid !== flowId));
};
if (loading) {
return (
@ -154,7 +204,7 @@ export default function AdminAgents() {
>
<form
onSubmit={handleSubmit}
onChange={() => setHasChanges(true)}
onChange={() => !selectedFlow && setHasChanges(true)}
ref={formEl}
className="flex flex-col w-full p-4 mt-10"
>
@ -180,6 +230,7 @@ export default function AdminAgents() {
skills={defaultSkills}
selectedSkill={selectedSkill}
handleClick={(skill) => {
setSelectedFlow(null);
setSelectedSkill(skill);
setShowSkillModal(true);
}}
@ -192,6 +243,7 @@ export default function AdminAgents() {
skills={configurableSkills}
selectedSkill={selectedSkill}
handleClick={(skill) => {
setSelectedFlow(null);
setSelectedSkill(skill);
setShowSkillModal(true);
}}
@ -205,7 +257,23 @@ export default function AdminAgents() {
<ImportedSkillList
skills={importedSkills}
selectedSkill={selectedSkill}
handleClick={setSelectedSkill}
handleClick={handleSkillClick}
/>
<div className="text-theme-text-primary flex items-center gap-x-2 mt-6">
<FlowArrow size={24} />
<p className="text-lg font-medium">Agent Flows</p>
</div>
<AgentFlowsList
flows={agentFlows}
selectedFlow={selectedFlow}
handleClick={handleFlowClick}
/>
<input
type="hidden"
name="system::active_agent_flows"
id="active_agent_flows"
value={activeFlowIds.join(",")}
/>
</div>
@ -232,7 +300,14 @@ export default function AdminAgents() {
<div className=" bg-theme-bg-secondary text-white rounded-xl p-4">
{SelectedSkillComponent ? (
<>
{selectedSkill.imported ? (
{selectedFlow ? (
<FlowPanel
flow={selectedFlow}
toggleFlow={toggleFlow}
enabled={activeFlowIds.includes(selectedFlow.uuid)}
onDelete={handleFlowDelete}
/>
) : selectedSkill.imported ? (
<ImportedSkillConfig
key={selectedSkill.hubId}
selectedSkill={selectedSkill}
@ -273,7 +348,9 @@ export default function AdminAgents() {
) : (
<div className="flex flex-col items-center justify-center h-full text-theme-text-secondary">
<Robot size={40} />
<p className="font-medium">Select an agent skill</p>
<p className="font-medium">
Select an agent skill or flow
</p>
</div>
)}
</div>
@ -294,7 +371,9 @@ export default function AdminAgents() {
>
<form
onSubmit={handleSubmit}
onChange={() => !selectedSkill.imported && setHasChanges(true)}
onChange={() =>
!selectedSkill?.imported && !selectedFlow && setHasChanges(true)
}
ref={formEl}
className="flex-1 flex gap-x-6 p-4 mt-10"
>
@ -308,40 +387,81 @@ export default function AdminAgents() {
type="hidden"
value={disabledAgentSkills.join(",")}
/>
<input
type="hidden"
name="system::active_agent_flows"
id="active_agent_flows"
value={activeFlowIds.join(",")}
/>
{/* Skill settings nav */}
<div className="flex flex-col gap-y-[18px]">
<div className="text-theme-text-primary flex items-center gap-x-2">
<Robot size={24} />
<p className="text-lg font-medium">Agent Skills</p>
{/* Skill settings nav - Make this section scrollable */}
<div className="flex flex-col min-w-[360px] h-[calc(100vh-90px)]">
<div className="flex-none mb-4">
<div className="text-theme-text-primary flex items-center gap-x-2">
<Robot size={24} />
<p className="text-lg font-medium">Agent Skills</p>
</div>
</div>
{/* Default skills list */}
<SkillList
skills={defaultSkills}
selectedSkill={selectedSkill}
handleClick={setSelectedSkill}
activeSkills={Object.keys(defaultSkills).filter(
(skill) => !disabledAgentSkills.includes(skill)
)}
/>
{/* Configurable skills */}
<SkillList
skills={configurableSkills}
selectedSkill={selectedSkill}
handleClick={setSelectedSkill}
activeSkills={agentSkills}
/>
<div className="flex-1 overflow-y-auto pr-2 pb-4">
<div className="space-y-4">
{/* Default skills list */}
<SkillList
skills={defaultSkills}
selectedSkill={selectedSkill}
handleClick={handleSkillClick}
activeSkills={Object.keys(defaultSkills).filter(
(skill) => !disabledAgentSkills.includes(skill)
)}
/>
{/* Configurable skills */}
<SkillList
skills={configurableSkills}
selectedSkill={selectedSkill}
handleClick={handleSkillClick}
activeSkills={agentSkills}
/>
<div className="text-theme-text-primary flex items-center gap-x-2">
<Plug size={24} />
<p className="text-lg font-medium">Custom Skills</p>
<div className="text-theme-text-primary flex items-center gap-x-2 mt-4">
<Plug size={24} />
<p className="text-lg font-medium">Custom Skills</p>
</div>
<ImportedSkillList
skills={importedSkills}
selectedSkill={selectedSkill}
handleClick={handleSkillClick}
/>
<div className="text-theme-text-primary flex items-center justify-between gap-x-2 mt-4">
<div className="flex items-center gap-x-2">
<FlowArrow size={24} />
<p className="text-lg font-medium">Agent Flows</p>
</div>
{agentFlows.length === 0 ? (
<Link
to={paths.agents.builder()}
className="text-cta-button flex items-center gap-x-1 hover:underline"
>
<Hammer size={16} />
<p className="text-sm">Create Flow</p>
</Link>
) : (
<Link
to={paths.agents.builder()}
className="text-theme-text-secondary hover:text-cta-button flex items-center gap-x-1"
>
<Hammer size={16} />
<p className="text-sm">Open Builder</p>
</Link>
)}
</div>
<AgentFlowsList
flows={agentFlows}
selectedFlow={selectedFlow}
handleClick={handleFlowClick}
/>
</div>
</div>
<ImportedSkillList
skills={importedSkills}
selectedSkill={selectedSkill}
handleClick={setSelectedSkill}
/>
</div>
{/* Selected agent skill setting panel */}
@ -349,7 +469,14 @@ export default function AdminAgents() {
<div className="bg-theme-bg-secondary text-white rounded-xl flex-1 p-4">
{SelectedSkillComponent ? (
<>
{selectedSkill.imported ? (
{selectedFlow ? (
<FlowPanel
flow={selectedFlow}
toggleFlow={toggleFlow}
enabled={activeFlowIds.includes(selectedFlow.uuid)}
onDelete={handleFlowDelete}
/>
) : selectedSkill.imported ? (
<ImportedSkillConfig
key={selectedSkill.hubId}
selectedSkill={selectedSkill}
@ -390,7 +517,7 @@ export default function AdminAgents() {
) : (
<div className="flex flex-col items-center justify-center h-full text-theme-text-secondary">
<Robot size={40} />
<p className="font-medium">Select an agent skill</p>
<p className="font-medium">Select an agent skill or flow</p>
</div>
)}
</div>

View file

@ -24,9 +24,9 @@ const markdown = markdownIt({
<div class="flex gap-2">
<code class="text-xs">${lang || ""}</code>
</div>
<button data-code-snippet data-code="code-${uuid}" class="flex items-center gap-x-2">
<svg stroke="currentColor" fill="none" stroke-width="2" viewBox="0 0 24 24" stroke-linecap="round" stroke-linejoin="round" class="h-4 w-4" height="1em" width="1em" xmlns="http://www.w3.org/2000/svg"><path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2"></path><rect x="8" y="2" width="8" height="4" rx="1" ry="1"></rect></svg>
<p>Copy code</p>
<button data-code-snippet data-code="code-${uuid}" class="flex items-center gap-x-1">
<svg stroke="currentColor" fill="none" stroke-width="2" viewBox="0 0 24 24" stroke-linecap="round" stroke-linejoin="round" class="h-3 w-3" height="1em" width="1em" xmlns="http://www.w3.org/2000/svg"><path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2"></path><rect x="8" y="2" width="8" height="4" rx="1" ry="1"></rect></svg>
<p class="text-xs" style="margin: 0px;padding: 0px;">Copy block</p>
</button>
</div>
<pre class="whitespace-pre-wrap">` +
@ -40,9 +40,9 @@ const markdown = markdownIt({
`<div class="whitespace-pre-line w-full hljs ${theme} light:border-solid light:border light:border-gray-700 rounded-lg px-4 pb-4 relative font-mono font-normal text-sm text-slate-200">
<div class="w-full flex items-center absolute top-0 left-0 text-slate-200 bg-stone-800 px-4 py-2 text-xs font-sans justify-between rounded-t-md">
<div class="flex gap-2"><code class="text-xs"></code></div>
<button data-code-snippet data-code="code-${uuid}" class="flex items-center gap-x-2">
<svg stroke="currentColor" fill="none" stroke-width="2" viewBox="0 0 24 24" stroke-linecap="round" stroke-linejoin="round" class="h-4 w-4" height="1em" width="1em" xmlns="http://www.w3.org/2000/svg"><path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2"></path><rect x="8" y="2" width="8" height="4" rx="1" ry="1"></rect></svg>
<p>Copy code</p>
<button data-code-snippet data-code="code-${uuid}" class="flex items-center gap-x-1">
<svg stroke="currentColor" fill="none" stroke-width="2" viewBox="0 0 24 24" stroke-linecap="round" stroke-linejoin="round" class="h-3 w-3" height="1em" width="1em" xmlns="http://www.w3.org/2000/svg"><path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2"></path><rect x="8" y="2" width="8" height="4" rx="1" ry="1"></rect></svg>
<p class="text-xs" style="margin: 0px;padding: 0px;">Copy block</p>
</button>
</div>
<pre class="whitespace-pre-wrap">` +
@ -55,6 +55,11 @@ const markdown = markdownIt({
// Add custom renderer for strong tags to handle theme colors
markdown.renderer.rules.strong_open = () => '<strong class="text-white">';
markdown.renderer.rules.strong_close = () => "</strong>";
markdown.renderer.rules.link_open = (tokens, idx) => {
const token = tokens[idx];
const href = token.attrs.find((attr) => attr[0] === "href");
return `<a href="${href[1]}" target="_blank" rel="noopener noreferrer">`;
};
// Custom renderer for responsive images rendered in markdown
markdown.renderer.rules.image = function (tokens, idx) {

View file

@ -139,6 +139,14 @@ export default {
return `/settings/beta-features`;
},
},
agents: {
builder: () => {
return `/settings/agents/builder`;
},
editAgent: (uuid) => {
return `/settings/agents/builder/${uuid}`;
},
},
communityHub: {
website: () => {
return import.meta.env.DEV

View file

@ -29,6 +29,7 @@ export default {
"historical-msg-user": "#2C2F35",
outline: "#4E5153",
"primary-button": "var(--theme-button-primary)",
"cta-button": "var(--theme-button-cta)",
secondary: "#2C2F36",
"dark-input": "#18181B",
"mobile-onboarding": "#2C2F35",

1
server/.gitignore vendored
View file

@ -9,6 +9,7 @@ storage/vector-cache/*.json
storage/exports
storage/imports
storage/plugins/agent-skills/*
storage/plugins/agent-flows/*
!storage/documents/DOCUMENTS.md
logs/server.log
*.db

View file

@ -0,0 +1,202 @@
const { AgentFlows } = require("../utils/agentFlows");
const {
flexUserRoleValid,
ROLES,
} = require("../utils/middleware/multiUserProtected");
const { validatedRequest } = require("../utils/middleware/validatedRequest");
const { Telemetry } = require("../models/telemetry");
function agentFlowEndpoints(app) {
if (!app) return;
// Save a flow configuration
app.post(
"/agent-flows/save",
[validatedRequest, flexUserRoleValid([ROLES.admin])],
async (request, response) => {
try {
const { name, config, uuid } = request.body;
if (!name || !config) {
return response.status(400).json({
success: false,
error: "Name and config are required",
});
}
const flow = AgentFlows.saveFlow(name, config, uuid);
if (!flow) {
return response.status(500).json({
success: false,
error: "Failed to save flow",
});
}
if (!uuid) {
await Telemetry.sendTelemetry("agent_flow_created", {
blockCount: config.blocks?.length || 0,
});
}
return response.status(200).json({
success: true,
flow,
});
} catch (error) {
console.error("Error saving flow:", error);
return response.status(500).json({
success: false,
error: error.message,
});
}
}
);
// List all available flows
app.get(
"/agent-flows/list",
[validatedRequest, flexUserRoleValid([ROLES.admin])],
async (_request, response) => {
try {
const flows = AgentFlows.listFlows();
return response.status(200).json({
success: true,
flows,
});
} catch (error) {
console.error("Error listing flows:", error);
return response.status(500).json({
success: false,
error: error.message,
});
}
}
);
// Get a specific flow by UUID
app.get(
"/agent-flows/:uuid",
[validatedRequest, flexUserRoleValid([ROLES.admin])],
async (request, response) => {
try {
const { uuid } = request.params;
const flow = AgentFlows.loadFlow(uuid);
if (!flow) {
return response.status(404).json({
success: false,
error: "Flow not found",
});
}
return response.status(200).json({
success: true,
flow,
});
} catch (error) {
console.error("Error getting flow:", error);
return response.status(500).json({
success: false,
error: error.message,
});
}
}
);
// Run a specific flow
// app.post(
// "/agent-flows/:uuid/run",
// [validatedRequest, flexUserRoleValid([ROLES.admin])],
// async (request, response) => {
// try {
// const { uuid } = request.params;
// const { variables = {} } = request.body;
// // TODO: Implement flow execution
// console.log("Running flow with UUID:", uuid);
// await Telemetry.sendTelemetry("agent_flow_executed", {
// variableCount: Object.keys(variables).length,
// });
// return response.status(200).json({
// success: true,
// results: {
// success: true,
// results: "test",
// variables: variables,
// },
// });
// } catch (error) {
// console.error("Error running flow:", error);
// return response.status(500).json({
// success: false,
// error: error.message,
// });
// }
// }
// );
// Delete a specific flow
app.delete(
"/agent-flows/:uuid",
[validatedRequest, flexUserRoleValid([ROLES.admin])],
async (request, response) => {
try {
const { uuid } = request.params;
const { success } = AgentFlows.deleteFlow(uuid);
if (!success) {
return response.status(500).json({
success: false,
error: "Failed to delete flow",
});
}
return response.status(200).json({
success,
});
} catch (error) {
console.error("Error deleting flow:", error);
return response.status(500).json({
success: false,
error: error.message,
});
}
}
);
// Toggle flow active status
app.post(
"/agent-flows/:uuid/toggle",
[validatedRequest, flexUserRoleValid([ROLES.admin])],
async (request, response) => {
try {
const { uuid } = request.params;
const { active } = request.body;
const flow = AgentFlows.loadFlow(uuid);
if (!flow) {
return response
.status(404)
.json({ success: false, error: "Flow not found" });
}
flow.config.active = active;
const { success } = AgentFlows.saveFlow(flow.name, flow.config, uuid);
if (!success) {
return response
.status(500)
.json({ success: false, error: "Failed to update flow" });
}
return response.json({ success: true, flow });
} catch (error) {
console.error("Error toggling flow:", error);
response.status(500).json({ success: false, error: error.message });
}
}
);
}
module.exports = { agentFlowEndpoints };

View file

@ -26,6 +26,7 @@ const { agentWebsocket } = require("./endpoints/agentWebsocket");
const { experimentalEndpoints } = require("./endpoints/experimental");
const { browserExtensionEndpoints } = require("./endpoints/browserExtension");
const { communityHubEndpoints } = require("./endpoints/communityHub");
const { agentFlowEndpoints } = require("./endpoints/agentFlows");
const app = express();
const apiRouter = express.Router();
const FILE_LIMIT = "3GB";
@ -61,6 +62,7 @@ agentWebsocket(apiRouter);
experimentalEndpoints(apiRouter);
developerEndpoints(app, apiRouter);
communityHubEndpoints(apiRouter);
agentFlowEndpoints(apiRouter);
// Externally facing embedder endpoints
embeddedEndpoints(apiRouter);

View file

@ -3,8 +3,9 @@ const {
LLMPerformanceMonitor,
} = require("../../helpers/chat/LLMPerformanceMonitor");
const {
handleDefaultStreamResponseV2,
formatChatHistory,
writeResponseChunk,
clientAbortedHandler,
} = require("../../helpers/chat/responses");
const { toValidNumber } = require("../../http");
@ -142,6 +143,21 @@ class GenericOpenAiLLM {
];
}
/**
* Parses and prepends reasoning from the response and returns the full text response.
* @param {Object} response
* @returns {string}
*/
#parseReasoningFromResponse({ message }) {
let textResponse = message?.content;
if (
!!message?.reasoning_content &&
message.reasoning_content.trim().length > 0
)
textResponse = `<think>${message.reasoning_content}</think>${textResponse}`;
return textResponse;
}
async getChatCompletion(messages = null, { temperature = 0.7 }) {
const result = await LLMPerformanceMonitor.measureAsyncFunction(
this.openai.chat.completions
@ -163,7 +179,7 @@ class GenericOpenAiLLM {
return null;
return {
textResponse: result.output.choices[0].message.content,
textResponse: this.#parseReasoningFromResponse(result.output.choices[0]),
metrics: {
prompt_tokens: result.output?.usage?.prompt_tokens || 0,
completion_tokens: result.output?.usage?.completion_tokens || 0,
@ -191,8 +207,141 @@ class GenericOpenAiLLM {
return measuredStreamRequest;
}
// TODO: This is a copy of the generic handleStream function in responses.js
// to specifically handle the DeepSeek reasoning model `reasoning_content` field.
// When or if ever possible, we should refactor this to be in the generic function.
handleStream(response, stream, responseProps) {
return handleDefaultStreamResponseV2(response, stream, responseProps);
const { uuid = uuidv4(), sources = [] } = responseProps;
let hasUsageMetrics = false;
let usage = {
completion_tokens: 0,
};
return new Promise(async (resolve) => {
let fullText = "";
let reasoningText = "";
// Establish listener to early-abort a streaming response
// in case things go sideways or the user does not like the response.
// We preserve the generated text but continue as if chat was completed
// to preserve previously generated content.
const handleAbort = () => {
stream?.endMeasurement(usage);
clientAbortedHandler(resolve, fullText);
};
response.on("close", handleAbort);
try {
for await (const chunk of stream) {
const message = chunk?.choices?.[0];
const token = message?.delta?.content;
const reasoningToken = message?.delta?.reasoning_content;
if (
chunk.hasOwnProperty("usage") && // exists
!!chunk.usage && // is not null
Object.values(chunk.usage).length > 0 // has values
) {
if (chunk.usage.hasOwnProperty("prompt_tokens")) {
usage.prompt_tokens = Number(chunk.usage.prompt_tokens);
}
if (chunk.usage.hasOwnProperty("completion_tokens")) {
hasUsageMetrics = true; // to stop estimating counter
usage.completion_tokens = Number(chunk.usage.completion_tokens);
}
}
// Reasoning models will always return the reasoning text before the token text.
if (reasoningToken) {
// If the reasoning text is empty (''), we need to initialize it
// and send the first chunk of reasoning text.
if (reasoningText.length === 0) {
writeResponseChunk(response, {
uuid,
sources: [],
type: "textResponseChunk",
textResponse: `<think>${reasoningToken}`,
close: false,
error: false,
});
reasoningText += `<think>${reasoningToken}`;
continue;
} else {
writeResponseChunk(response, {
uuid,
sources: [],
type: "textResponseChunk",
textResponse: reasoningToken,
close: false,
error: false,
});
reasoningText += reasoningToken;
}
}
// If the reasoning text is not empty, but the reasoning token is empty
// and the token text is not empty we need to close the reasoning text and begin sending the token text.
if (!!reasoningText && !reasoningToken && token) {
writeResponseChunk(response, {
uuid,
sources: [],
type: "textResponseChunk",
textResponse: `</think>`,
close: false,
error: false,
});
fullText += `${reasoningText}</think>`;
reasoningText = "";
}
if (token) {
fullText += token;
// If we never saw a usage metric, we can estimate them by number of completion chunks
if (!hasUsageMetrics) usage.completion_tokens++;
writeResponseChunk(response, {
uuid,
sources: [],
type: "textResponseChunk",
textResponse: token,
close: false,
error: false,
});
}
if (
message?.hasOwnProperty("finish_reason") && // Got valid message and it is an object with finish_reason
message.finish_reason !== "" &&
message.finish_reason !== null
) {
writeResponseChunk(response, {
uuid,
sources,
type: "textResponseChunk",
textResponse: "",
close: true,
error: false,
});
response.removeListener("close", handleAbort);
stream?.endMeasurement(usage);
resolve(fullText);
break; // Break streaming when a valid finish_reason is first encountered
}
}
} catch (e) {
console.log(`\x1b[43m\x1b[34m[STREAMING ERROR]\x1b[0m ${e.message}`);
writeResponseChunk(response, {
uuid,
type: "abort",
textResponse: null,
sources: [],
close: true,
error: e.message,
});
stream?.endMeasurement(usage);
resolve(fullText);
}
});
}
// Simple wrapper for dynamic embedder & normalize interface for all LLM implementations

View file

@ -0,0 +1,146 @@
const { FLOW_TYPES } = require("./flowTypes");
const executeApiCall = require("./executors/api-call");
const executeWebsite = require("./executors/website");
const executeFile = require("./executors/file");
const executeCode = require("./executors/code");
const executeLLMInstruction = require("./executors/llm-instruction");
const executeWebScraping = require("./executors/web-scraping");
const { Telemetry } = require("../../models/telemetry");
class FlowExecutor {
constructor() {
this.variables = {};
this.introspect = () => {}; // Default no-op introspect
this.logger = console.info; // Default console.info
}
attachLogging(introspectFn, loggerFn) {
this.introspect = introspectFn || (() => {});
this.logger = loggerFn || console.info;
}
// Utility to replace variables in config
replaceVariables(config) {
const deepReplace = (obj) => {
if (typeof obj === "string") {
return obj.replace(/\${([^}]+)}/g, (match, varName) => {
return this.variables[varName] !== undefined
? this.variables[varName]
: match;
});
}
if (Array.isArray(obj)) {
return obj.map((item) => deepReplace(item));
}
if (obj && typeof obj === "object") {
const result = {};
for (const [key, value] of Object.entries(obj)) {
result[key] = deepReplace(value);
}
return result;
}
return obj;
};
return deepReplace(config);
}
// Main execution method
async executeStep(step) {
const config = this.replaceVariables(step.config);
let result;
// Create execution context with introspect
const context = {
introspect: this.introspect,
variables: this.variables,
logger: this.logger,
model: process.env.LLM_PROVIDER_MODEL || "gpt-4",
provider: process.env.LLM_PROVIDER || "openai",
};
switch (step.type) {
case FLOW_TYPES.START.type:
// For start blocks, we just initialize variables if they're not already set
if (config.variables) {
config.variables.forEach((v) => {
if (v.name && !this.variables[v.name]) {
this.variables[v.name] = v.value || "";
}
});
}
result = this.variables;
break;
case FLOW_TYPES.API_CALL.type:
result = await executeApiCall(config, context);
break;
case FLOW_TYPES.WEBSITE.type:
result = await executeWebsite(config, context);
break;
case FLOW_TYPES.FILE.type:
result = await executeFile(config, context);
break;
case FLOW_TYPES.CODE.type:
result = await executeCode(config, context);
break;
case FLOW_TYPES.LLM_INSTRUCTION.type:
result = await executeLLMInstruction(config, context);
break;
case FLOW_TYPES.WEB_SCRAPING.type:
result = await executeWebScraping(config, context);
break;
default:
throw new Error(`Unknown flow type: ${step.type}`);
}
// Store result in variable if specified
if (config.resultVariable || config.responseVariable) {
const varName = config.resultVariable || config.responseVariable;
this.variables[varName] = result;
}
return result;
}
// Execute entire flow
async executeFlow(
flow,
initialVariables = {},
introspectFn = null,
loggerFn = null
) {
await Telemetry.sendTelemetry("agent_flow_execution_started");
// Initialize variables with both initial values and any passed-in values
this.variables = {
...(
flow.config.steps.find((s) => s.type === "start")?.config?.variables ||
[]
).reduce((acc, v) => ({ ...acc, [v.name]: v.value }), {}),
...initialVariables, // This will override any default values with passed-in values
};
this.attachLogging(introspectFn, loggerFn);
const results = [];
for (const step of flow.config.steps) {
try {
const result = await this.executeStep(step);
results.push({ success: true, result });
} catch (error) {
results.push({ success: false, error: error.message });
break;
}
}
return {
success: results.every((r) => r.success),
results,
variables: this.variables,
};
}
}
module.exports = {
FlowExecutor,
FLOW_TYPES,
};

View file

@ -0,0 +1,60 @@
const { safeJsonParse } = require("../../http");
/**
* Execute an API call flow step
* @param {Object} config Flow step configuration
* @param {Object} context Execution context with introspect function
* @returns {Promise<string>} Response data
*/
async function executeApiCall(config, context) {
const { url, method, headers = [], body, bodyType, formData } = config;
const { introspect } = context;
introspect(`Making ${method} request to external API...`);
const requestConfig = {
method,
headers: headers.reduce((acc, h) => ({ ...acc, [h.key]: h.value }), {}),
};
if (["POST", "PUT", "PATCH"].includes(method)) {
if (bodyType === "form") {
const formDataObj = new URLSearchParams();
formData.forEach(({ key, value }) => formDataObj.append(key, value));
requestConfig.body = formDataObj.toString();
requestConfig.headers["Content-Type"] =
"application/x-www-form-urlencoded";
} else if (bodyType === "json") {
const parsedBody = safeJsonParse(body, null);
if (parsedBody !== null) {
requestConfig.body = JSON.stringify(parsedBody);
}
requestConfig.headers["Content-Type"] = "application/json";
} else if (bodyType === "text") {
requestConfig.body = String(body);
} else {
requestConfig.body = body;
}
}
try {
introspect(`Sending body to ${url}: ${requestConfig?.body || "No body"}`);
const response = await fetch(url, requestConfig);
if (!response.ok) {
introspect(`Request failed with status ${response.status}`);
throw new Error(`HTTP error! status: ${response.status}`);
}
introspect(`API call completed`);
return await response
.text()
.then((text) =>
safeJsonParse(text, "Failed to parse output from API call block")
);
} catch (error) {
console.error(error);
throw new Error(`API Call failed: ${error.message}`);
}
}
module.exports = executeApiCall;

View file

@ -0,0 +1,12 @@
/**
* Execute a code flow step
* @param {Object} config Flow step configuration
* @returns {Promise<Object>} Result of the code execution
*/
async function executeCode(config) {
// For now just log what would happen
console.log("Code execution:", config);
return { success: true, message: "Code executed (placeholder)" };
}
module.exports = executeCode;

View file

@ -0,0 +1,12 @@
/**
* Execute a file operation flow step
* @param {Object} config Flow step configuration
* @returns {Promise<Object>} Result of the file operation
*/
async function executeFile(config) {
// For now just log what would happen
console.log("File operation:", config);
return { success: true, message: "File operation executed (placeholder)" };
}
module.exports = executeFile;

View file

@ -0,0 +1,49 @@
const AIbitat = require("../../agents/aibitat");
/**
* Execute an LLM instruction flow step
* @param {Object} config Flow step configuration
* @param {{introspect: Function, variables: Object, logger: Function}} context Execution context with introspect function
* @returns {Promise<string>} Processed result
*/
async function executeLLMInstruction(config, context) {
const { instruction, inputVariable, resultVariable } = config;
const { introspect, variables, logger } = context;
introspect(`Processing data with LLM instruction...`);
if (!variables[inputVariable]) {
logger(`Input variable ${inputVariable} not found`);
throw new Error(`Input variable ${inputVariable} not found`);
}
try {
introspect(`Sending request to LLM...`);
// Ensure the input is a string since we are sending it to the LLM direct as a message
let input = variables[inputVariable];
if (typeof input === "object") input = JSON.stringify(input);
if (typeof input !== "string") input = String(input);
const aibitat = new AIbitat();
const provider = aibitat.getProviderForConfig(aibitat.defaultProvider);
const completion = await provider.complete([
{
role: "system",
content: `Follow these instructions carefully: ${instruction}`,
},
{
role: "user",
content: input,
},
]);
introspect(`Successfully received LLM response`);
if (resultVariable) config.resultVariable = resultVariable;
return completion.result;
} catch (error) {
logger(`LLM processing failed: ${error.message}`, error);
throw new Error(`LLM processing failed: ${error.message}`);
}
}
module.exports = executeLLMInstruction;

View file

@ -0,0 +1,55 @@
const { CollectorApi } = require("../../collectorApi");
const { TokenManager } = require("../../helpers/tiktoken");
const Provider = require("../../agents/aibitat/providers/ai-provider");
const { summarizeContent } = require("../../agents/aibitat/utils/summarize");
/**
* Execute a web scraping flow step
* @param {Object} config Flow step configuration
* @param {Object} context Execution context with introspect function
* @returns {Promise<string>} Scraped content
*/
async function executeWebScraping(config, context) {
const { url } = config;
const { introspect, model, provider } = context;
if (!url) {
throw new Error("URL is required for web scraping");
}
introspect(`Scraping the content of ${url}`);
const { success, content } = await new CollectorApi().getLinkContent(url);
if (!success) {
introspect(`Could not scrape ${url}. Cannot use this page's content.`);
throw new Error("URL could not be scraped and no content was found.");
}
introspect(`Successfully scraped content from ${url}`);
if (!content || content?.length === 0) {
throw new Error("There was no content to be collected or read.");
}
const tokenCount = new TokenManager(model).countFromString(content);
const contextLimit = Provider.contextLimit(provider, model);
if (tokenCount < contextLimit) {
return content;
}
introspect(
`This page's content is way too long. I will summarize it right now.`
);
const summary = await summarizeContent({
provider,
model,
content,
});
introspect(`Successfully summarized content`);
return summary;
}
module.exports = executeWebScraping;

View file

@ -0,0 +1,12 @@
/**
* Execute a website interaction flow step
* @param {Object} config Flow step configuration
* @returns {Promise<Object>} Result of the website interaction
*/
async function executeWebsite(config) {
// For now just log what would happen
console.log("Website action:", config);
return { success: true, message: "Website action executed (placeholder)" };
}
module.exports = executeWebsite;

View file

@ -0,0 +1,133 @@
const FLOW_TYPES = {
START: {
type: "start",
description: "Initialize flow variables",
parameters: {
variables: {
type: "array",
description: "List of variables to initialize",
},
},
},
API_CALL: {
type: "apiCall",
description: "Make an HTTP request to an API endpoint",
parameters: {
url: { type: "string", description: "The URL to make the request to" },
method: { type: "string", description: "HTTP method (GET, POST, etc.)" },
headers: {
type: "array",
description: "Request headers as key-value pairs",
},
bodyType: {
type: "string",
description: "Type of request body (json, form)",
},
body: {
type: "string",
description:
"Request body content. If body type is json, always return a valid json object. If body type is form, always return a valid form data object.",
},
formData: { type: "array", description: "Form data as key-value pairs" },
responseVariable: {
type: "string",
description: "Variable to store the response",
},
},
examples: [
{
url: "https://api.example.com/data",
method: "GET",
headers: [{ key: "Authorization", value: "Bearer 1234567890" }],
},
],
},
WEBSITE: {
type: "website",
description: "Interact with a website",
parameters: {
url: { type: "string", description: "The URL of the website" },
selector: {
type: "string",
description: "CSS selector for targeting elements",
},
action: {
type: "string",
description: "Action to perform (read, click, type)",
},
value: { type: "string", description: "Value to use for type action" },
resultVariable: {
type: "string",
description: "Variable to store the result",
},
},
},
FILE: {
type: "file",
description: "Perform file system operations",
parameters: {
path: { type: "string", description: "Path to the file" },
operation: {
type: "string",
description: "Operation to perform (read, write, append)",
},
content: {
type: "string",
description: "Content for write/append operations",
},
resultVariable: {
type: "string",
description: "Variable to store the result",
},
},
},
CODE: {
type: "code",
description: "Execute code in various languages",
parameters: {
language: {
type: "string",
description: "Programming language to execute",
},
code: { type: "string", description: "Code to execute" },
resultVariable: {
type: "string",
description: "Variable to store the result",
},
},
},
LLM_INSTRUCTION: {
type: "llmInstruction",
description: "Process data using LLM instructions",
parameters: {
instruction: {
type: "string",
description: "The instruction for the LLM to follow",
},
inputVariable: {
type: "string",
description: "Variable containing the input data to process",
},
resultVariable: {
type: "string",
description: "Variable to store the processed result",
},
},
},
WEB_SCRAPING: {
type: "webScraping",
description: "Scrape content from a webpage",
parameters: {
url: {
type: "string",
description: "The URL of the webpage to scrape",
},
resultVariable: {
type: "string",
description: "Variable to store the scraped content",
},
},
},
};
module.exports.FLOW_TYPES = FLOW_TYPES;

View file

@ -0,0 +1,238 @@
const fs = require("fs");
const path = require("path");
const { v4: uuidv4 } = require("uuid");
const { FlowExecutor } = require("./executor");
const { normalizePath } = require("../files");
const { safeJsonParse } = require("../http");
class AgentFlows {
static flowsDir = process.env.STORAGE_DIR
? path.join(process.env.STORAGE_DIR, "plugins", "agent-flows")
: path.join(process.cwd(), "storage", "plugins", "agent-flows");
constructor() {}
/**
* Ensure flows directory exists
* @returns {Boolean} True if directory exists, false otherwise
*/
static createOrCheckFlowsDir() {
try {
if (fs.existsSync(AgentFlows.flowsDir)) return true;
fs.mkdirSync(AgentFlows.flowsDir, { recursive: true });
return true;
} catch (error) {
console.error("Failed to create flows directory:", error);
return false;
}
}
/**
* Helper to get all flow files with their contents
* @returns {Object} Map of flow UUID to flow config
*/
static getAllFlows() {
AgentFlows.createOrCheckFlowsDir();
const files = fs.readdirSync(AgentFlows.flowsDir);
const flows = {};
for (const file of files) {
if (!file.endsWith(".json")) continue;
try {
const filePath = path.join(AgentFlows.flowsDir, file);
const content = fs.readFileSync(normalizePath(filePath), "utf8");
const config = JSON.parse(content);
const id = file.replace(".json", "");
flows[id] = config;
} catch (error) {
console.error(`Error reading flow file ${file}:`, error);
}
}
return flows;
}
/**
* Load a flow configuration by UUID
* @param {string} uuid - The UUID of the flow to load
* @returns {Object|null} Flow configuration or null if not found
*/
static loadFlow(uuid) {
try {
const flowJsonPath = normalizePath(
path.join(AgentFlows.flowsDir, `${uuid}.json`)
);
if (!uuid || !fs.existsSync(flowJsonPath)) return null;
const flow = safeJsonParse(fs.readFileSync(flowJsonPath, "utf8"), null);
if (!flow) return null;
return {
name: flow.name,
uuid,
config: flow,
};
} catch (error) {
console.error("Failed to load flow:", error);
return null;
}
}
/**
* Save a flow configuration
* @param {string} name - The name of the flow
* @param {Object} config - The flow configuration
* @param {string|null} uuid - Optional UUID for the flow
* @returns {Object} Result of the save operation
*/
static saveFlow(name, config, uuid = null) {
try {
AgentFlows.createOrCheckFlowsDir();
if (!uuid) uuid = uuidv4();
const normalizedUuid = normalizePath(`${uuid}.json`);
const filePath = path.join(AgentFlows.flowsDir, normalizedUuid);
fs.writeFileSync(filePath, JSON.stringify({ ...config, name }, null, 2));
return { success: true, uuid };
} catch (error) {
console.error("Failed to save flow:", error);
return { success: false, error: error.message };
}
}
/**
* List all available flows
* @returns {Array} Array of flow summaries
*/
static listFlows() {
try {
const flows = AgentFlows.getAllFlows();
return Object.entries(flows).map(([uuid, flow]) => ({
name: flow.name,
uuid,
description: flow.description,
active: flow.active !== false,
}));
} catch (error) {
console.error("Failed to list flows:", error);
return [];
}
}
/**
* Delete a flow by UUID
* @param {string} uuid - The UUID of the flow to delete
* @returns {Object} Result of the delete operation
*/
static deleteFlow(uuid) {
try {
const filePath = normalizePath(
path.join(AgentFlows.flowsDir, `${uuid}.json`)
);
if (!fs.existsSync(filePath)) throw new Error(`Flow ${uuid} not found`);
fs.rmSync(filePath);
return { success: true };
} catch (error) {
console.error("Failed to delete flow:", error);
return { success: false, error: error.message };
}
}
/**
* Execute a flow by UUID
* @param {string} uuid - The UUID of the flow to execute
* @param {Object} variables - Initial variables for the flow
* @param {Function} introspectFn - Function to introspect the flow
* @param {Function} loggerFn - Function to log the flow
* @returns {Promise<Object>} Result of flow execution
*/
static async executeFlow(
uuid,
variables = {},
introspectFn = null,
loggerFn = null
) {
const flow = AgentFlows.loadFlow(uuid);
if (!flow) throw new Error(`Flow ${uuid} not found`);
const flowExecutor = new FlowExecutor();
return await flowExecutor.executeFlow(
flow,
variables,
introspectFn,
loggerFn
);
}
/**
* Get all active flows as plugins that can be loaded into the agent
* @returns {string[]} Array of flow names in @@flow_{uuid} format
*/
static activeFlowPlugins() {
const flows = AgentFlows.getAllFlows();
return Object.entries(flows)
.filter(([_, flow]) => flow.active !== false)
.map(([uuid]) => `@@flow_${uuid}`);
}
/**
* Load a flow plugin by its UUID
* @param {string} uuid - The UUID of the flow to load
* @returns {Object|null} Plugin configuration or null if not found
*/
static loadFlowPlugin(uuid) {
const flow = AgentFlows.loadFlow(uuid);
if (!flow) return null;
const startBlock = flow.config.steps?.find((s) => s.type === "start");
const variables = startBlock?.config?.variables || [];
return {
name: `flow_${uuid}`,
description: `Execute agent flow: ${flow.name}`,
plugin: (_runtimeArgs = {}) => ({
name: `flow_${uuid}`,
description: flow.description || `Execute agent flow: ${flow.name}`,
setup: (aibitat) => {
aibitat.function({
name: `flow_${uuid}`,
description: flow.description || `Execute agent flow: ${flow.name}`,
parameters: {
type: "object",
properties: variables.reduce((acc, v) => {
if (v.name) {
acc[v.name] = {
type: "string",
description:
v.description || `Value for variable ${v.name}`,
};
}
return acc;
}, {}),
},
handler: async (args) => {
aibitat.introspect(`Executing flow: ${flow.name}`);
const result = await AgentFlows.executeFlow(
uuid,
args,
aibitat.introspect,
aibitat.handlerProps.log
);
if (!result.success) {
aibitat.introspect(
`Flow failed: ${result.results[0]?.error || "Unknown error"}`
);
return `Flow execution failed: ${result.results[0]?.error || "Unknown error"}`;
}
aibitat.introspect(`${flow.name} completed successfully`);
return typeof result === "object"
? JSON.stringify(result)
: String(result);
},
});
},
}),
flowName: flow.name,
};
}
}
module.exports.AgentFlows = AgentFlows;

View file

@ -3,6 +3,7 @@ const { SystemSettings } = require("../../models/systemSettings");
const { safeJsonParse } = require("../http");
const Provider = require("./aibitat/providers/ai-provider");
const ImportedPlugin = require("./imported");
const { AgentFlows } = require("../agentFlows");
// This is a list of skills that are built-in and default enabled.
const DEFAULT_SKILLS = [
@ -28,7 +29,8 @@ const WORKSPACE_AGENT = {
role: Provider.systemPrompt(provider),
functions: [
...(await agentSkillsFromSystemSettings()),
...(await ImportedPlugin.activeImportedPlugins()),
...ImportedPlugin.activeImportedPlugins(),
...AgentFlows.activeFlowPlugins(),
],
};
},

View file

@ -59,9 +59,9 @@ class ImportedPlugin {
/**
* Loads plugins from `plugins` folder in storage that are custom loaded and defined.
* only loads plugins that are active: true.
* @returns {Promise<string[]>} - array of plugin names to be loaded later.
* @returns {string[]} - array of plugin names to be loaded later.
*/
static async activeImportedPlugins() {
static activeImportedPlugins() {
const plugins = [];
this.checkPluginFolderExists();
const folders = fs.readdirSync(path.resolve(pluginsPath));

View file

@ -7,6 +7,7 @@ const { WorkspaceChats } = require("../../models/workspaceChats");
const { safeJsonParse } = require("../http");
const { USER_AGENT, WORKSPACE_AGENT } = require("./defaults");
const ImportedPlugin = require("./imported");
const { AgentFlows } = require("../agentFlows");
class AgentHandler {
#invocationUUID;
@ -337,26 +338,27 @@ class AgentHandler {
for (const [param, definition] of Object.entries(config)) {
if (
definition.required &&
(!args.hasOwnProperty(param) || args[param] === null)
(!Object.prototype.hasOwnProperty.call(args, param) ||
args[param] === null)
) {
this.log(
`'${param}' required parameter for '${pluginName}' plugin is missing. Plugin may not function or crash agent.`
);
continue;
}
callOpts[param] = args.hasOwnProperty(param)
callOpts[param] = Object.prototype.hasOwnProperty.call(args, param)
? args[param]
: definition.default || null;
}
return callOpts;
}
#attachPlugins(args) {
async #attachPlugins(args) {
for (const name of this.#funcsToLoad) {
// Load child plugin
if (name.includes("#")) {
const [parent, childPluginName] = name.split("#");
if (!AgentPlugins.hasOwnProperty(parent)) {
if (!Object.prototype.hasOwnProperty.call(AgentPlugins, parent)) {
this.log(
`${parent} is not a valid plugin. Skipping inclusion to agent cluster.`
);
@ -385,6 +387,24 @@ class AgentHandler {
continue;
}
// Load flow plugin. This is marked by `@@flow_` in the array of functions to load.
if (name.startsWith("@@flow_")) {
const uuid = name.replace("@@flow_", "");
const plugin = AgentFlows.loadFlowPlugin(uuid);
if (!plugin) {
this.log(
`Flow ${uuid} not found in flows directory. Skipping inclusion to agent cluster.`
);
continue;
}
this.aibitat.use(plugin.plugin());
this.log(
`Attached flow ${plugin.name} (${plugin.flowName}) plugin to Agent cluster`
);
continue;
}
// Load imported plugin. This is marked by `@@` in the array of functions to load.
// and is the @@hubID of the plugin.
if (name.startsWith("@@")) {
@ -407,7 +427,7 @@ class AgentHandler {
}
// Load single-stage plugin.
if (!AgentPlugins.hasOwnProperty(name)) {
if (!Object.prototype.hasOwnProperty.call(AgentPlugins, name)) {
this.log(
`${name} is not a valid plugin. Skipping inclusion to agent cluster.`
);
@ -480,7 +500,7 @@ class AgentHandler {
await this.#loadAgents();
// Attach all required plugins for functions to operate.
this.#attachPlugins(args);
await this.#attachPlugins(args);
}
startAgentCluster() {

View file

@ -78,7 +78,7 @@ function safeJsonParse(jsonString, fallback = null) {
}
try {
return extract(jsonString)[0];
return extract(jsonString)?.[0] || fallback;
} catch {}
return fallback;