mirror of
https://github.com/Mintplex-Labs/anything-llm.git
synced 2025-03-13 05:32:24 +00:00
Compare commits
5 commits
69f8a6cee8
...
a46b7f0251
Author | SHA1 | Date | |
---|---|---|---|
|
a46b7f0251 | ||
|
0e7fee41ca | ||
|
e5f3fb0892 | ||
|
e2148d4803 | ||
|
cc3d619061 |
47 changed files with 3265 additions and 69 deletions
.github/workflows
README.mdfrontend
src
App.jsxindex.css
tailwind.config.jscomponents
PrivateRoute
WorkspaceChat/ChatContainer/ChatHistory
media/logo
models
pages/Admin
utils
server
.gitignore
endpoints
index.jsutils
2
.github/workflows/dev-build.yaml
vendored
2
.github/workflows/dev-build.yaml
vendored
|
@ -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/*'
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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} />}
|
||||
|
|
|
@ -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()} />
|
||||
);
|
||||
|
|
|
@ -18,6 +18,10 @@ import {
|
|||
} from "../ThoughtContainer";
|
||||
|
||||
const DOMPurify = createDOMPurify(window);
|
||||
DOMPurify.setConfig({
|
||||
ADD_ATTR: ["target", "rel"],
|
||||
});
|
||||
|
||||
const HistoricalMessage = ({
|
||||
uuid = v4(),
|
||||
message,
|
||||
|
|
|
@ -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">
|
||||
|
|
|
@ -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 {
|
||||
|
|
BIN
frontend/src/media/logo/anything-llm-infinity.png
Normal file
BIN
frontend/src/media/logo/anything-llm-infinity.png
Normal file
Binary file not shown.
After ![]() (image error) Size: 552 B |
149
frontend/src/models/agentFlows.js
Normal file
149
frontend/src/models/agentFlows.js
Normal 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;
|
68
frontend/src/pages/Admin/AgentBuilder/AddBlockMenu/index.jsx
Normal file
68
frontend/src/pages/Admin/AgentBuilder/AddBlockMenu/index.jsx
Normal 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>
|
||||
);
|
||||
}
|
305
frontend/src/pages/Admin/AgentBuilder/BlockList/index.jsx
Normal file
305
frontend/src/pages/Admin/AgentBuilder/BlockList/index.jsx
Normal 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 };
|
130
frontend/src/pages/Admin/AgentBuilder/HeaderMenu/index.jsx
Normal file
130
frontend/src/pages/Admin/AgentBuilder/HeaderMenu/index.jsx
Normal 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 →
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
361
frontend/src/pages/Admin/AgentBuilder/index.jsx
Normal file
361
frontend/src/pages/Admin/AgentBuilder/index.jsx
Normal 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>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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;
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
124
frontend/src/pages/Admin/Agents/AgentFlows/FlowPanel.jsx
Normal file
124
frontend/src/pages/Admin/Agents/AgentFlows/FlowPanel.jsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
57
frontend/src/pages/Admin/Agents/AgentFlows/index.jsx
Normal file
57
frontend/src/pages/Admin/Agents/AgentFlows/index.jsx
Normal 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>
|
||||
);
|
||||
}
|
|
@ -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">
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
1
server/.gitignore
vendored
|
@ -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
|
||||
|
|
202
server/endpoints/agentFlows.js
Normal file
202
server/endpoints/agentFlows.js
Normal 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 };
|
|
@ -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);
|
||||
|
|
|
@ -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
|
||||
|
|
146
server/utils/agentFlows/executor.js
Normal file
146
server/utils/agentFlows/executor.js
Normal 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,
|
||||
};
|
60
server/utils/agentFlows/executors/api-call.js
Normal file
60
server/utils/agentFlows/executors/api-call.js
Normal 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;
|
12
server/utils/agentFlows/executors/code.js
Normal file
12
server/utils/agentFlows/executors/code.js
Normal 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;
|
12
server/utils/agentFlows/executors/file.js
Normal file
12
server/utils/agentFlows/executors/file.js
Normal 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;
|
49
server/utils/agentFlows/executors/llm-instruction.js
Normal file
49
server/utils/agentFlows/executors/llm-instruction.js
Normal 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;
|
55
server/utils/agentFlows/executors/web-scraping.js
Normal file
55
server/utils/agentFlows/executors/web-scraping.js
Normal 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;
|
12
server/utils/agentFlows/executors/website.js
Normal file
12
server/utils/agentFlows/executors/website.js
Normal 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;
|
133
server/utils/agentFlows/flowTypes.js
Normal file
133
server/utils/agentFlows/flowTypes.js
Normal 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;
|
238
server/utils/agentFlows/index.js
Normal file
238
server/utils/agentFlows/index.js
Normal 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;
|
|
@ -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(),
|
||||
],
|
||||
};
|
||||
},
|
||||
|
|
|
@ -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));
|
||||
|
|
|
@ -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() {
|
||||
|
|
|
@ -78,7 +78,7 @@ function safeJsonParse(jsonString, fallback = null) {
|
|||
}
|
||||
|
||||
try {
|
||||
return extract(jsonString)[0];
|
||||
return extract(jsonString)?.[0] || fallback;
|
||||
} catch {}
|
||||
|
||||
return fallback;
|
||||
|
|
Loading…
Add table
Reference in a new issue