[FEAT] Agent skills UI redesign (#1565)

* WIP agent settings redesign

* WIP rework new agent skill UI

* WIP save bar/agent styles

* WIP update settings fix

* desktop agent config UI implementation

* remove unneeded files

* fix sql and web browsing plugins not starting & add default badges

* fix serply merge conflict

* review: cleanup unused files/folders/components

* refactor components

* refactor components

* fix order of customized skills

---------

Co-authored-by: timothycarambat <rambat1010@gmail.com>
This commit is contained in:
Sean Hatfield 2024-06-12 10:52:32 -07:00 committed by GitHub
parent c74ba35023
commit 9c3014de70
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
33 changed files with 800 additions and 439 deletions

View File

@ -4,7 +4,9 @@
"target": "esnext", "target": "esnext",
"jsx": "react", "jsx": "react",
"paths": { "paths": {
"@/*": ["./src/*"] "@/*": [
"./src/*"
]
} }
} }
} }

View File

@ -21,6 +21,7 @@ const AdminInvites = lazy(() => import("@/pages/Admin/Invitations"));
const AdminWorkspaces = lazy(() => import("@/pages/Admin/Workspaces")); const AdminWorkspaces = lazy(() => import("@/pages/Admin/Workspaces"));
const AdminSystem = lazy(() => import("@/pages/Admin/System")); const AdminSystem = lazy(() => import("@/pages/Admin/System"));
const AdminLogs = lazy(() => import("@/pages/Admin/Logging")); const AdminLogs = lazy(() => import("@/pages/Admin/Logging"));
const AdminAgents = lazy(() => import("@/pages/Admin/Agents"));
const GeneralChats = lazy(() => import("@/pages/GeneralSettings/Chats")); const GeneralChats = lazy(() => import("@/pages/GeneralSettings/Chats"));
const GeneralAppearance = lazy( const GeneralAppearance = lazy(
() => import("@/pages/GeneralSettings/Appearance") () => import("@/pages/GeneralSettings/Appearance")
@ -106,6 +107,10 @@ export default function App() {
path="/settings/vector-database" path="/settings/vector-database"
element={<AdminRoute Component={GeneralVectorDatabase} />} element={<AdminRoute Component={GeneralVectorDatabase} />}
/> />
<Route
path="/settings/agents"
element={<AdminRoute Component={AdminAgents} />}
/>
<Route <Route
path="/settings/event-logs" path="/settings/event-logs"
element={<AdminRoute Component={AdminLogs} />} element={<AdminRoute Component={AdminLogs} />}

View File

@ -0,0 +1,32 @@
import { Warning } from "@phosphor-icons/react";
export default function ContextualSaveBar({
showing = false,
onSave,
onCancel,
}) {
if (!showing) return null;
return (
<div className="fixed top-0 left-0 right-0 h-14 bg-[#18181B] flex items-center justify-end px-4 z-[9999]">
<div className="absolute left-1/2 transform -translate-x-1/2 flex items-center gap-x-2">
<Warning size={18} className="text-white" />
<p className="text-white font-medium text-xs">Unsaved Changes</p>
</div>
<div className="flex items-center gap-x-2">
<button
className="border-none text-white font-medium text-sm px-[10px] py-[6px] rounded-md bg-white/5 hover:bg-white/10"
onClick={onCancel}
>
Cancel
</button>
<button
className="border-none text-[#222628] font-medium text-sm px-[10px] py-[6px] rounded-md bg-[#46C8FF] hover:bg-[#3DB5E8]"
onClick={onSave}
>
Save
</button>
</div>
</div>
);
}

View File

@ -22,6 +22,7 @@ import {
EyeSlash, EyeSlash,
SplitVertical, SplitVertical,
Microphone, Microphone,
Robot,
} from "@phosphor-icons/react"; } from "@phosphor-icons/react";
import useUser from "@/hooks/useUser"; import useUser from "@/hooks/useUser";
import { USER_BACKGROUND_COLOR } from "@/utils/constants"; import { USER_BACKGROUND_COLOR } from "@/utils/constants";
@ -258,6 +259,15 @@ const SidebarOptions = ({ user = null }) => (
flex={true} flex={true}
allowedRole={["admin", "manager"]} allowedRole={["admin", "manager"]}
/> />
<Option
href={paths.settings.agentSkills()}
btnText="Agent Skills"
icon={<Robot className="h-5 w-5 flex-shrink-0" />}
user={user}
flex={true}
allowedRole={["admin", "manager"]}
/>
<Option <Option
href={paths.settings.appearance()} href={paths.settings.appearance()}
btnText="Appearance" btnText="Appearance"

Binary file not shown.

After

Width:  |  Height:  |  Size: 169 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 172 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 171 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 171 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 171 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 170 KiB

View File

@ -0,0 +1,25 @@
import { Tooltip } from "react-tooltip";
export function DefaultBadge({ title }) {
return (
<>
<span
className="w-fit"
data-tooltip-id={`default-skill-${title}`}
data-tooltip-content="This skill is enabled by default and cannot be turned off."
>
<div className="flex items-center gap-x-1 w-fit rounded-full bg-[#F4FFD0]/10 px-2.5 py-0.5 text-sm font-medium text-sky-400 shadow-sm cursor-pointer">
<div className="text-[#F4FFD0] text-[12px] leading-[15px]">
Default
</div>
</div>
</span>
<Tooltip
id={`default-skill-${title}`}
place="bottom"
delayShow={300}
className="tooltip !text-xs"
/>
</>
);
}

View File

@ -0,0 +1,29 @@
import React from "react";
import { DefaultBadge } from "../Badges/default";
export default function DefaultSkillPanel({ title, description, image, icon }) {
return (
<div className="p-2">
<div className="flex flex-col gap-y-[18px] max-w-[500px]">
<div className="flex w-full justify-between items-center">
<div className="flex items-center gap-x-2">
{icon &&
React.createElement(icon, {
size: 24,
color: "white",
weight: "bold",
})}
<label htmlFor="name" className="text-white text-md font-bold">
{title}
</label>
<DefaultBadge title={title} />
</div>
</div>
<img src={image} alt={title} className="w-full rounded-md" />
<p className="text-white text-opacity-60 text-xs font-medium py-1.5">
{description}
</p>
</div>
</div>
);
}

View File

@ -1,21 +1,30 @@
import React from "react"; import React from "react";
export default function GenericSkill({
export default function GenericSkillPanel({
title, title,
description, description,
skill, skill,
toggleSkill, toggleSkill,
enabled = false, enabled = false,
disabled = false, disabled = false,
image,
icon,
}) { }) {
return ( return (
<div className="border-b border-white/40 pb-4"> <div className="p-2">
<div className="flex flex-col"> <div className="flex flex-col gap-y-[18px] max-w-[500px]">
<div className="flex w-full justify-between items-center"> <div className="flex items-center gap-x-2">
<label htmlFor="name" className="block input-label"> {icon &&
React.createElement(icon, {
size: 24,
color: "white",
weight: "bold",
})}
<label htmlFor="name" className="text-white text-md font-bold">
{title} {title}
</label> </label>
<label <label
className={`border-none relative inline-flex items-center mt-2 ${ className={`border-none relative inline-flex items-center ml-auto ${
disabled ? "cursor-not-allowed" : "cursor-pointer" disabled ? "cursor-not-allowed" : "cursor-pointer"
}`} }`}
> >
@ -24,12 +33,13 @@ export default function GenericSkill({
disabled={disabled} disabled={disabled}
className="peer sr-only" className="peer sr-only"
checked={enabled} checked={enabled}
onClick={() => toggleSkill(skill)} onChange={() => toggleSkill(skill)}
/> />
<div className="peer-disabled:opacity-50 pointer-events-none peer h-6 w-11 rounded-full bg-stone-400 after:absolute after:left-[2px] after:top-[2px] after:h-5 after:w-5 after:rounded-full after:shadow-xl after:border after:border-gray-600 after:bg-white after:box-shadow-md after:transition-all after:content-[''] peer-checked:bg-lime-300 peer-checked:after:translate-x-full peer-checked:after:border-white peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-800"></div> <div className="peer-disabled:opacity-50 pointer-events-none peer h-6 w-11 rounded-full bg-stone-400 after:absolute after:left-[2px] after:top-[2px] after:h-5 after:w-5 after:rounded-full after:shadow-xl after:border after:border-gray-600 after:bg-white after:box-shadow-md after:transition-all after:content-[''] peer-checked:bg-lime-300 peer-checked:after:translate-x-full peer-checked:after:border-white peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-800"></div>
<span className="ml-3 text-sm font-medium text-gray-900 dark:text-gray-300"></span> <span className="ml-3 text-sm font-medium text-gray-900 dark:text-gray-300"></span>
</label> </label>
</div> </div>
<img src={image} alt={title} className="w-full rounded-md" />
<p className="text-white text-opacity-60 text-xs font-medium py-1.5"> <p className="text-white text-opacity-60 text-xs font-medium py-1.5">
{description} {description}
</p> </p>

View File

@ -74,8 +74,8 @@ export default function NewSQLConnection({ isOpen, closeModal, onSubmit }) {
// to the parent container form so we don't have nested forms. // to the parent container form so we don't have nested forms.
return createPortal( return createPortal(
<ModalWrapper isOpen={isOpen}> <ModalWrapper isOpen={isOpen}>
<div className="relative w-1/3 max-h-full "> <div className="relative w-1/3 max-h-full mt-8">
<div className="relative bg-main-gradient rounded-xl shadow-[0_4px_14px_rgba(0,0,0,0.25)] max-h-[90vh] overflow-y-scroll no-scroll"> <div className="relative bg-main-gradient rounded-xl shadow-[0_4px_14px_rgba(0,0,0,0.25)] max-h-[85vh] overflow-y-scroll no-scroll">
<div className="flex items-start justify-between p-4 border-b rounded-t border-gray-500/50"> <div className="flex items-start justify-between p-4 border-b rounded-t border-gray-500/50">
<h3 className="text-xl font-semibold text-white"> <h3 className="text-xl font-semibold text-white">
New SQL Connection New SQL Connection

View File

@ -0,0 +1,117 @@
import React, { useState } from "react";
import DBConnection from "./DBConnection";
import { Plus, Database } from "@phosphor-icons/react";
import NewSQLConnection from "./NewConnectionModal";
import { useModal } from "@/hooks/useModal";
import SQLAgentImage from "@/media/agents/sql-agent.png";
export default function AgentSQLConnectorSelection({
skill,
settings,
toggleSkill,
enabled = false,
setHasChanges,
}) {
const { isOpen, openModal, closeModal } = useModal();
const [connections, setConnections] = useState(
settings?.preferences?.agent_sql_connections || []
);
return (
<>
<div className="p-2">
<div className="flex flex-col gap-y-[18px] max-w-[500px]">
<div className="flex items-center gap-x-2">
<Database size={24} color="white" weight="bold" />
<label htmlFor="name" className="text-white text-md font-bold">
SQL Agent
</label>
<label className="border-none relative inline-flex cursor-pointer items-center ml-auto">
<input
type="checkbox"
className="peer sr-only"
checked={enabled}
onChange={() => toggleSkill(skill)}
/>
<div className="pointer-events-none peer h-6 w-11 rounded-full bg-stone-400 after:absolute after:left-[2px] after:top-[2px] after:h-5 after:w-5 after:rounded-full after:shadow-xl after:border after:border-gray-600 after:bg-white after:box-shadow-md after:transition-all after:content-[''] peer-checked:bg-lime-300 peer-checked:after:translate-x-full peer-checked:after:border-white peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-800"></div>
<span className="ml-3 text-sm font-medium text-gray-900 dark:text-gray-300"></span>
</label>
</div>
<img
src={SQLAgentImage}
alt="SQL Agent"
className="w-full rounded-md"
/>
<p className="text-white text-opacity-60 text-xs font-medium py-1.5">
Enable your agent to be able to leverage SQL to answer you questions
by connecting to various SQL database providers.
</p>
{enabled && (
<>
<input
name="system::agent_sql_connections"
type="hidden"
value={JSON.stringify(connections)}
/>
<input
type="hidden"
value={JSON.stringify(
connections.filter((conn) => conn.action !== "remove")
)}
/>
<div className="flex flex-col mt-2 gap-y-2">
<p className="text-white font-semibold text-sm">
Your database connections
</p>
<div className="flex flex-col gap-y-3">
{connections
.filter((connection) => connection.action !== "remove")
.map((connection) => (
<DBConnection
key={connection.database_id}
connection={connection}
onRemove={(databaseId) => {
setHasChanges(true);
setConnections((prev) =>
prev.map((conn) => {
if (conn.database_id === databaseId)
return { ...conn, action: "remove" };
return conn;
})
);
}}
/>
))}
<button
type="button"
onClick={openModal}
className="w-fit relative flex h-[40px] items-center border-none hover:bg-slate-600/20 rounded-lg"
>
<div className="flex w-full gap-x-2 items-center p-4">
<div className="bg-zinc-600 p-2 rounded-lg h-[24px] w-[24px] flex items-center justify-center">
<Plus
weight="bold"
size={14}
className="shrink-0 text-slate-100"
/>
</div>
<p className="text-left text-slate-100 text-sm">
New SQL connection
</p>
</div>
</button>
</div>
</div>
</>
)}
</div>
</div>
<NewSQLConnection
isOpen={isOpen}
closeModal={closeModal}
onSubmit={(newDb) =>
setConnections((prev) => [...prev, { action: "add", ...newDb }])
}
/>
</>
);
}

View File

@ -0,0 +1,227 @@
import React, { useEffect, useRef, useState } from "react";
import AnythingLLMIcon from "@/media/logo/anything-llm-icon.png";
import GoogleSearchIcon from "./icons/google.png";
import SerperDotDevIcon from "./icons/serper.png";
import BingSearchIcon from "./icons/bing.png";
import SerplySearchIcon from "./icons/serply.png";
import {
CaretUpDown,
MagnifyingGlass,
X,
ListMagnifyingGlass,
} from "@phosphor-icons/react";
import SearchProviderItem from "./SearchProviderItem";
import WebSearchImage from "@/media/agents/scrape-websites.png";
import {
SerperDotDevOptions,
GoogleSearchOptions,
BingSearchOptions,
SerplySearchOptions,
} from "./SearchProviderOptions";
const SEARCH_PROVIDERS = [
{
name: "Please make a selection",
value: "none",
logo: AnythingLLMIcon,
options: () => <React.Fragment />,
description:
"Web search will be disabled until a provider and keys are provided.",
},
{
name: "Google Search Engine",
value: "google-search-engine",
logo: GoogleSearchIcon,
options: (settings) => <GoogleSearchOptions settings={settings} />,
description:
"Web search powered by a custom Google Search Engine. Free for 100 queries per day.",
},
{
name: "Serper.dev",
value: "serper-dot-dev",
logo: SerperDotDevIcon,
options: (settings) => <SerperDotDevOptions settings={settings} />,
description:
"Serper.dev web-search. Free account with a 2,500 calls, but then paid.",
},
{
name: "Bing Search",
value: "bing-search",
logo: BingSearchIcon,
options: (settings) => <BingSearchOptions settings={settings} />,
description:
"Web search powered by the Bing Search API. Free for 1000 queries per month.",
},
{
name: "Serply.io",
value: "serply-engine",
logo: SerplySearchIcon,
options: (settings) => <SerplySearchOptions settings={settings} />,
description:
"Serply.io web-search. Free account with a 100 calls/month forever.",
},
];
export default function AgentWebSearchSelection({
skill,
settings,
toggleSkill,
enabled = false,
setHasChanges,
}) {
const searchInputRef = useRef(null);
const [filteredResults, setFilteredResults] = useState([]);
const [selectedProvider, setSelectedProvider] = useState("none");
const [searchQuery, setSearchQuery] = useState("");
const [searchMenuOpen, setSearchMenuOpen] = useState(false);
function updateChoice(selection) {
setSearchQuery("");
setSelectedProvider(selection);
setSearchMenuOpen(false);
setHasChanges(true);
}
function handleXButton() {
if (searchQuery.length > 0) {
setSearchQuery("");
if (searchInputRef.current) searchInputRef.current.value = "";
} else {
setSearchMenuOpen(!searchMenuOpen);
}
}
useEffect(() => {
const filtered = SEARCH_PROVIDERS.filter((provider) =>
provider.name.toLowerCase().includes(searchQuery.toLowerCase())
);
setFilteredResults(filtered);
}, [searchQuery, selectedProvider]);
useEffect(() => {
setSelectedProvider(settings?.preferences?.agent_search_provider ?? "none");
}, [settings?.preferences?.agent_search_provider]);
const selectedSearchProviderObject = SEARCH_PROVIDERS.find(
(provider) => provider.value === selectedProvider
);
return (
<div className="p-2">
<div className="flex flex-col gap-y-[18px] max-w-[500px]">
<div className="flex items-center gap-x-2">
<ListMagnifyingGlass size={24} color="white" weight="bold" />
<label htmlFor="name" className="text-white text-md font-bold">
Live web search and browsing
</label>
<label className="border-none relative inline-flex cursor-pointer items-center ml-auto">
<input
type="checkbox"
className="peer sr-only"
checked={enabled}
onChange={() => toggleSkill(skill)}
/>
<div className="pointer-events-none peer h-6 w-11 rounded-full bg-stone-400 after:absolute after:left-[2px] after:top-[2px] after:h-5 after:w-5 after:rounded-full after:shadow-xl after:border after:border-gray-600 after:bg-white after:box-shadow-md after:transition-all after:content-[''] peer-checked:bg-lime-300 peer-checked:after:translate-x-full peer-checked:after:border-white peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-800"></div>
<span className="ml-3 text-sm font-medium text-gray-900 dark:text-gray-300"></span>
</label>
</div>
<img
src={WebSearchImage}
alt="Web Search"
className="w-full rounded-md"
/>
<p className="text-white text-opacity-60 text-xs font-medium py-1.5">
Enable your agent to search the web to answer your questions by
connecting to a web-search (SERP) provider. Web search during agent
sessions will not work until this is set up.
</p>
<div hidden={!enabled}>
<div className="relative">
<input
type="hidden"
name="system::agent_search_provider"
value={selectedProvider}
/>
{searchMenuOpen && (
<div
className="fixed top-0 left-0 w-full h-full bg-black bg-opacity-70 backdrop-blur-sm z-10"
onClick={() => setSearchMenuOpen(false)}
/>
)}
{searchMenuOpen ? (
<div className="absolute top-0 left-0 w-full max-w-[640px] max-h-[310px] overflow-auto white-scrollbar min-h-[64px] bg-[#18181B] rounded-lg flex flex-col justify-between cursor-pointer border-2 border-[#46C8FF] z-20">
<div className="w-full flex flex-col gap-y-1">
<div className="flex items-center sticky top-0 border-b border-[#9CA3AF] mx-4 bg-[#18181B]">
<MagnifyingGlass
size={20}
weight="bold"
className="absolute left-4 z-30 text-white -ml-4 my-2"
/>
<input
type="text"
name="web-provider-search"
autoComplete="off"
placeholder="Search available web-search providers"
className="border-none -ml-4 my-2 bg-transparent z-20 pl-12 h-[38px] w-full px-4 py-1 text-sm outline-none focus:border-white text-white placeholder:text-white placeholder:font-medium"
onChange={(e) => setSearchQuery(e.target.value)}
ref={searchInputRef}
onKeyDown={(e) => {
if (e.key === "Enter") e.preventDefault();
}}
/>
<X
size={20}
weight="bold"
className="cursor-pointer text-white hover:text-[#9CA3AF]"
onClick={handleXButton}
/>
</div>
<div className="flex-1 pl-4 pr-2 flex flex-col gap-y-1 overflow-y-auto white-scrollbar pb-4">
{filteredResults.map((provider) => {
return (
<SearchProviderItem
provider={provider}
key={provider.name}
checked={selectedProvider === provider.value}
onClick={() => updateChoice(provider.value)}
/>
);
})}
</div>
</div>
</div>
) : (
<button
className="w-full max-w-[640px] h-[64px] bg-[#18181B] rounded-lg flex items-center p-[14px] justify-between cursor-pointer border-2 border-transparent hover:border-[#46C8FF] transition-all duration-300"
type="button"
onClick={() => setSearchMenuOpen(true)}
>
<div className="flex gap-x-4 items-center">
<img
src={selectedSearchProviderObject.logo}
alt={`${selectedSearchProviderObject.name} logo`}
className="w-10 h-10 rounded-md"
/>
<div className="flex flex-col text-left">
<div className="text-sm font-semibold text-white">
{selectedSearchProviderObject.name}
</div>
<div className="mt-1 text-xs text-[#D2D5DB]">
{selectedSearchProviderObject.description}
</div>
</div>
</div>
<CaretUpDown size={24} weight="bold" className="text-white" />
</button>
)}
</div>
{selectedProvider !== "none" && (
<div className="mt-4 flex flex-col gap-y-1">
{selectedSearchProviderObject.options(settings)}
</div>
)}
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,232 @@
import { useEffect, useRef, useState } from "react";
import Sidebar from "@/components/SettingsSidebar";
import { isMobile } from "react-device-detect";
import Admin from "@/models/admin";
import System from "@/models/system";
import showToast from "@/utils/toast";
import { CaretRight, Robot } from "@phosphor-icons/react";
import ContextualSaveBar from "@/components/ContextualSaveBar";
import { castToType } from "@/utils/types";
import { FullScreenLoader } from "@/components/Preloader";
import { defaultSkills, configurableSkills } from "./skills";
import { DefaultBadge } from "./Badges/default";
export default function AdminAgents() {
const [hasChanges, setHasChanges] = useState(false);
const [settings, setSettings] = useState({});
const [selectedSkill, setSelectedSkill] = useState("");
const [agentSkills, setAgentSkills] = useState([]);
const [loading, setLoading] = useState(true);
const formEl = useRef(null);
// Alert user if they try to leave the page with unsaved changes
useEffect(() => {
const handleBeforeUnload = (event) => {
if (hasChanges) {
event.preventDefault();
event.returnValue = "";
}
};
window.addEventListener("beforeunload", handleBeforeUnload);
return () => {
window.removeEventListener("beforeunload", handleBeforeUnload);
};
}, [hasChanges]);
useEffect(() => {
async function fetchSettings() {
const _settings = await System.keys();
const _preferences = await Admin.systemPreferences();
setSettings({ ..._settings, preferences: _preferences.settings } ?? {});
setAgentSkills(_preferences.settings?.default_agent_skills ?? []);
setLoading(false);
}
fetchSettings();
}, []);
const toggleAgentSkill = (skillName) => {
setAgentSkills((prev) => {
const updatedSkills = prev.includes(skillName)
? prev.filter((name) => name !== skillName)
: [...prev, skillName];
setHasChanges(true);
return updatedSkills;
});
};
const handleSubmit = async (e) => {
e.preventDefault();
const data = {
workspace: {},
system: {},
env: {},
};
const form = new FormData(formEl.current);
for (var [key, value] of form.entries()) {
if (key.startsWith("system::")) {
const [_, label] = key.split("system::");
data.system[label] = String(value);
continue;
}
if (key.startsWith("env::")) {
const [_, label] = key.split("env::");
data.env[label] = String(value);
continue;
}
data.workspace[key] = castToType(key, value);
}
const { success } = await Admin.updateSystemPreferences(data.system);
await System.updateSystem(data.env);
if (success) {
const _settings = await System.keys();
const _preferences = await Admin.systemPreferences();
setSettings({ ..._settings, preferences: _preferences.settings } ?? {});
setAgentSkills(_preferences.settings?.default_agent_skills ?? []);
showToast(`Agent preferences saved successfully.`, "success", {
clear: true,
});
} else {
showToast(`Agent preferences failed to save.`, "error", { clear: true });
}
setHasChanges(false);
};
const SelectedSkillComponent =
configurableSkills[selectedSkill]?.component ||
defaultSkills[selectedSkill]?.component;
if (loading) {
return (
<div
style={{ height: isMobile ? "100%" : "calc(100% - 32px)" }}
className="relative md:ml-[2px] md:mr-[16px] md:my-[16px] md:rounded-[16px] w-full h-full flex justify-center items-center"
>
<FullScreenLoader />
</div>
);
}
return (
<div
id="workspace-agent-settings-container"
className="w-screen h-screen overflow-hidden bg-sidebar flex"
>
<Sidebar />
<div
style={{ height: isMobile ? "100%" : "calc(100% - 32px)" }}
className="relative md:ml-[2px] md:mr-[16px] md:my-[16px] md:rounded-[16px] w-full h-full flex"
>
<form
onSubmit={handleSubmit}
onChange={() => setHasChanges(true)}
ref={formEl}
className="flex-1 flex gap-x-6 p-4 mt-10"
>
<input
name="system::default_agent_skills"
type="hidden"
value={agentSkills.join(",")}
/>
{/* Skill settings nav */}
<div className="flex flex-col gap-y-[18px]">
<div className="text-white flex items-center gap-x-2">
<Robot size={24} />
<p className="text-lg font-medium">Agent Skills</p>
</div>
{/* Default skills list */}
<SkillList
isDefault={true}
skills={defaultSkills}
selectedSkill={selectedSkill}
handleClick={setSelectedSkill}
/>
{/* Configurable skills */}
<SkillList
skills={configurableSkills}
selectedSkill={selectedSkill}
handleClick={setSelectedSkill}
activeSkills={agentSkills}
/>
</div>
{/* Selected agent skill setting panel */}
<div className="flex-[2] flex flex-col gap-y-[18px] mt-10">
<div className="bg-[#303237] text-white rounded-xl flex-1 p-4">
{SelectedSkillComponent ? (
<SelectedSkillComponent
skill={configurableSkills[selectedSkill]?.skill}
settings={settings}
toggleSkill={toggleAgentSkill}
enabled={agentSkills.includes(
configurableSkills[selectedSkill]?.skill
)}
setHasChanges={setHasChanges}
{...(configurableSkills[selectedSkill] ||
defaultSkills[selectedSkill])}
/>
) : (
<div className="flex flex-col items-center justify-center h-full text-white/60">
<Robot size={40} />
<p className="font-medium">Select an agent skill</p>
</div>
)}
</div>
</div>
</form>
<ContextualSaveBar
showing={hasChanges}
onSave={handleSubmit}
onCancel={() => setHasChanges(false)}
/>
</div>
</div>
);
}
function SkillList({
isDefault = false,
skills = [],
selectedSkill = null,
handleClick = null,
activeSkills = [],
}) {
if (skills.length === 0) return null;
return (
<div className="bg-white/5 text-white min-w-[360px] w-fit rounded-xl">
{Object.entries(skills).map(([skill, settings], index) => (
<div
key={skill}
className={`py-3 px-4 flex items-center justify-between ${
index === 0 ? "rounded-t-xl" : ""
} ${
index === Object.keys(skills).length - 1
? "rounded-b-xl"
: "border-b border-white/10"
} cursor-pointer transition-all duration-300 hover:bg-white/5 ${
selectedSkill === skill ? "bg-white/10" : ""
}`}
onClick={() => handleClick?.(skill)}
>
<div className="text-sm font-light">{settings.title}</div>
<div className="flex items-center gap-x-2">
{isDefault ? (
<DefaultBadge title={skill} />
) : (
<div className="text-sm text-white/60 font-medium">
{activeSkills.includes(skill) ? "On" : "Off"}
</div>
)}
<CaretRight size={14} weight="bold" className="text-white/80" />
</div>
</div>
))}
</div>
);
}

View File

@ -0,0 +1,73 @@
import AgentWebSearchSelection from "./WebSearchSelection";
import AgentSQLConnectorSelection from "./SQLConnectorSelection";
import GenericSkillPanel from "./GenericSkillPanel";
import DefaultSkillPanel from "./DefaultSkillPanel";
import {
Brain,
File,
Browser,
ChartBar,
FileMagnifyingGlass,
} from "@phosphor-icons/react";
import RAGImage from "@/media/agents/rag-memory.png";
import SummarizeImage from "@/media/agents/view-summarize.png";
import ScrapeWebsitesImage from "@/media/agents/scrape-websites.png";
import GenerateChartsImage from "@/media/agents/generate-charts.png";
import GenerateSaveImages from "@/media/agents/generate-save-files.png";
export const defaultSkills = {
"rag-memory": {
title: "RAG & long-term memory",
description:
'Allow the agent to leverage your local documents to answer a query or ask the agent to "remember" pieces of content for long-term memory retrieval.',
component: DefaultSkillPanel,
icon: Brain,
image: RAGImage,
},
"view-summarize": {
title: "View & summarize documents",
description:
"Allow the agent to list and summarize the content of workspace files currently embedded.",
component: DefaultSkillPanel,
icon: File,
image: SummarizeImage,
},
"scrape-websites": {
title: "Scrape websites",
description: "Allow the agent to visit and scrape the content of websites.",
component: DefaultSkillPanel,
icon: Browser,
image: ScrapeWebsitesImage,
},
};
export const configurableSkills = {
"save-file": {
title: "Generate & save files to browser",
description:
"Enable the default agent to generate and write to files that can be saved to your computer.",
component: GenericSkillPanel,
skill: "save-file-to-browser",
icon: FileMagnifyingGlass,
image: GenerateSaveImages,
},
"create-chart": {
title: "Generate charts",
description:
"Enable the default agent to generate various types of charts from data provided or given in chat.",
component: GenericSkillPanel,
skill: "create-chart",
icon: ChartBar,
image: GenerateChartsImage,
},
"web-browsing": {
title: "Web Search",
component: AgentWebSearchSelection,
skill: "web-browsing",
},
"sql-agent": {
title: "SQL Connector",
component: AgentSQLConnectorSelection,
skill: "sql-agent",
},
};

View File

@ -1,111 +0,0 @@
import React, { useState } from "react";
import DBConnection from "./DBConnection";
import { Plus } from "@phosphor-icons/react";
import NewSQLConnection from "./NewConnectionModal";
import { useModal } from "@/hooks/useModal";
export default function AgentSQLConnectorSelection({
skill,
settings,
toggleSkill,
enabled = false,
setHasChanges,
}) {
const { isOpen, openModal, closeModal } = useModal();
const [connections, setConnections] = useState(
settings?.preferences?.agent_sql_connections || []
);
return (
<>
<div className="border-b border-white/40 pb-4">
<div className="flex flex-col">
<div className="flex w-full justify-between items-center">
<label htmlFor="name" className="block input-label">
SQL Agent
</label>
<label className="border-none relative inline-flex cursor-pointer items-center mt-2">
<input
type="checkbox"
className="peer sr-only"
checked={enabled}
onClick={() => toggleSkill(skill)}
/>
<div className="pointer-events-none peer h-6 w-11 rounded-full bg-stone-400 after:absolute after:left-[2px] after:top-[2px] after:h-5 after:w-5 after:rounded-full after:shadow-xl after:border after:border-gray-600 after:bg-white after:box-shadow-md after:transition-all after:content-[''] peer-checked:bg-lime-300 peer-checked:after:translate-x-full peer-checked:after:border-white peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-800"></div>
<span className="ml-3 text-sm font-medium text-gray-900 dark:text-gray-300"></span>
</label>
</div>
<p className="text-white text-opacity-60 text-xs font-medium py-1.5">
Enable your agent to be able to leverage SQL to answer you questions
by connecting to various SQL database providers.
</p>
</div>
{enabled && (
<>
<input
name="system::agent_sql_connections"
type="hidden"
value={JSON.stringify(connections)}
/>
<input
type="hidden"
value={JSON.stringify(
connections.filter((conn) => conn.action !== "remove")
)}
/>
<div className="flex flex-col mt-2 gap-y-2">
<p className="text-white font-semibold text-sm">
Your database connections
</p>
<div className="flex flex-col gap-y-3">
{connections
.filter((connection) => connection.action !== "remove")
.map((connection) => (
<DBConnection
key={connection.database_id}
connection={connection}
onRemove={(databaseId) => {
setConnections((prev) =>
prev.map((conn) => {
if (conn.database_id === databaseId)
return { ...conn, action: "remove" };
return conn;
})
);
}}
setHasChanges={setHasChanges}
/>
))}
<button
type="button"
onClick={openModal}
className="w-fit relative flex h-[40px] items-center border-none hover:bg-slate-600/20 rounded-lg"
>
<div className="flex w-full gap-x-2 items-center p-4">
<div className="bg-zinc-600 p-2 rounded-lg h-[24px] w-[24px] flex items-center justify-center">
<Plus
weight="bold"
size={14}
className="shrink-0 text-slate-100"
/>
</div>
<p className="text-left text-slate-100 text-sm">
New SQL connection
</p>
</div>
</button>
</div>
</div>
</>
)}
</div>
<NewSQLConnection
isOpen={isOpen}
closeModal={closeModal}
onSubmit={(newDb) =>
setConnections((prev) => [...prev, { action: "add", ...newDb }])
}
/>
</>
);
}

View File

@ -1,214 +0,0 @@
import React, { useEffect, useRef, useState } from "react";
import AnythingLLMIcon from "@/media/logo/anything-llm-icon.png";
import GoogleSearchIcon from "./icons/google.png";
import SerperDotDevIcon from "./icons/serper.png";
import BingSearchIcon from "./icons/bing.png";
import SerplySearchIcon from "./icons/serply.png";
import { CaretUpDown, MagnifyingGlass, X } from "@phosphor-icons/react";
import SearchProviderItem from "./SearchProviderItem";
import {
SerperDotDevOptions,
GoogleSearchOptions,
BingSearchOptions,
SerplySearchOptions,
} from "./SearchProviderOptions";
const SEARCH_PROVIDERS = [
{
name: "Please make a selection",
value: "none",
logo: AnythingLLMIcon,
options: () => <React.Fragment />,
description:
"Web search will be disabled until a provider and keys are provided.",
},
{
name: "Google Search Engine",
value: "google-search-engine",
logo: GoogleSearchIcon,
options: (settings) => <GoogleSearchOptions settings={settings} />,
description:
"Web search powered by a custom Google Search Engine. Free for 100 queries per day.",
},
{
name: "Serper.dev",
value: "serper-dot-dev",
logo: SerperDotDevIcon,
options: (settings) => <SerperDotDevOptions settings={settings} />,
description:
"Serper.dev web-search. Free account with a 2,500 calls, but then paid.",
},
{
name: "Bing Search",
value: "bing-search",
logo: BingSearchIcon,
options: (settings) => <BingSearchOptions settings={settings} />,
description:
"Web search powered by the Bing Search API. Free for 1000 queries per month.",
},
{
name: "Serply.io",
value: "serply-engine",
logo: SerplySearchIcon,
options: (settings) => <SerplySearchOptions settings={settings} />,
description:
"Serply.io web-search. Free account with a 100 calls/month forever.",
},
];
export default function AgentWebSearchSelection({
skill,
settings,
toggleSkill,
enabled = false,
}) {
const searchInputRef = useRef(null);
const [filteredResults, setFilteredResults] = useState([]);
const [selectedProvider, setSelectedProvider] = useState("none");
const [searchQuery, setSearchQuery] = useState("");
const [searchMenuOpen, setSearchMenuOpen] = useState(false);
function updateChoice(selection) {
setSearchQuery("");
setSelectedProvider(selection);
setSearchMenuOpen(false);
}
function handleXButton() {
if (searchQuery.length > 0) {
setSearchQuery("");
if (searchInputRef.current) searchInputRef.current.value = "";
} else {
setSearchMenuOpen(!searchMenuOpen);
}
}
useEffect(() => {
const filtered = SEARCH_PROVIDERS.filter((provider) =>
provider.name.toLowerCase().includes(searchQuery.toLowerCase())
);
setFilteredResults(filtered);
}, [searchQuery, selectedProvider]);
useEffect(() => {
setSelectedProvider(settings?.preferences?.agent_search_provider ?? "none");
}, [settings?.preferences?.agent_search_provider]);
const selectedSearchProviderObject = SEARCH_PROVIDERS.find(
(provider) => provider.value === selectedProvider
);
return (
<div className="border-b border-white/40 pb-4">
<div className="flex flex-col">
<div className="flex w-full justify-between items-center">
<label htmlFor="name" className="block input-label">
Live web search and browsing
</label>
<label className="border-none relative inline-flex cursor-pointer items-center mt-2">
<input
type="checkbox"
className="peer sr-only"
checked={enabled}
onClick={() => toggleSkill(skill)}
/>
<div className="pointer-events-none peer h-6 w-11 rounded-full bg-stone-400 after:absolute after:left-[2px] after:top-[2px] after:h-5 after:w-5 after:rounded-full after:shadow-xl after:border after:border-gray-600 after:bg-white after:box-shadow-md after:transition-all after:content-[''] peer-checked:bg-lime-300 peer-checked:after:translate-x-full peer-checked:after:border-white peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-800"></div>
<span className="ml-3 text-sm font-medium text-gray-900 dark:text-gray-300"></span>
</label>
</div>
<p className="text-white text-opacity-60 text-xs font-medium py-1.5">
Enable your agent to search the web to answer your questions by
connecting to a web-search (SERP) provider.
<br />
Web search during agent sessions will not work until this is set up.
</p>
</div>
<div hidden={!enabled}>
<div className="relative">
<input
type="hidden"
name="system::agent_search_provider"
value={selectedProvider}
/>
{searchMenuOpen && (
<div
className="fixed top-0 left-0 w-full h-full bg-black bg-opacity-70 backdrop-blur-sm z-10"
onClick={() => setSearchMenuOpen(false)}
/>
)}
{searchMenuOpen ? (
<div className="absolute top-0 left-0 w-full max-w-[640px] max-h-[310px] overflow-auto white-scrollbar min-h-[64px] bg-[#18181B] rounded-lg flex flex-col justify-between cursor-pointer border-2 border-[#46C8FF] z-20">
<div className="w-full flex flex-col gap-y-1">
<div className="flex items-center sticky top-0 border-b border-[#9CA3AF] mx-4 bg-[#18181B]">
<MagnifyingGlass
size={20}
weight="bold"
className="absolute left-4 z-30 text-white -ml-4 my-2"
/>
<input
type="text"
name="web-provider-search"
autoComplete="off"
placeholder="Search available web-search providers"
className="border-none -ml-4 my-2 bg-transparent z-20 pl-12 h-[38px] w-full px-4 py-1 text-sm outline-none focus:border-white text-white placeholder:text-white placeholder:font-medium"
onChange={(e) => setSearchQuery(e.target.value)}
ref={searchInputRef}
onKeyDown={(e) => {
if (e.key === "Enter") e.preventDefault();
}}
/>
<X
size={20}
weight="bold"
className="cursor-pointer text-white hover:text-[#9CA3AF]"
onClick={handleXButton}
/>
</div>
<div className="flex-1 pl-4 pr-2 flex flex-col gap-y-1 overflow-y-auto white-scrollbar pb-4">
{filteredResults.map((provider) => {
return (
<SearchProviderItem
provider={provider}
key={provider.name}
checked={selectedProvider === provider.value}
onClick={() => updateChoice(provider.value)}
/>
);
})}
</div>
</div>
</div>
) : (
<button
className="w-full max-w-[640px] h-[64px] bg-[#18181B] rounded-lg flex items-center p-[14px] justify-between cursor-pointer border-2 border-transparent hover:border-[#46C8FF] transition-all duration-300"
type="button"
onClick={() => setSearchMenuOpen(true)}
>
<div className="flex gap-x-4 items-center">
<img
src={selectedSearchProviderObject.logo}
alt={`${selectedSearchProviderObject.name} logo`}
className="w-10 h-10 rounded-md"
/>
<div className="flex flex-col text-left">
<div className="text-sm font-semibold text-white">
{selectedSearchProviderObject.name}
</div>
<div className="mt-1 text-xs text-[#D2D5DB]">
{selectedSearchProviderObject.description}
</div>
</div>
</div>
<CaretUpDown size={24} weight="bold" className="text-white" />
</button>
)}
</div>
{selectedProvider !== "none" && (
<div className="mt-4 flex flex-col gap-y-1">
{selectedSearchProviderObject.options(settings)}
</div>
)}
</div>
</div>
);
}

View File

@ -4,27 +4,25 @@ import showToast from "@/utils/toast";
import { castToType } from "@/utils/types"; import { castToType } from "@/utils/types";
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import AgentLLMSelection from "./AgentLLMSelection"; import AgentLLMSelection from "./AgentLLMSelection";
import AgentWebSearchSelection from "./WebSearchSelection";
import AgentSQLConnectorSelection from "./SQLConnectorSelection";
import GenericSkill from "./GenericSkill";
import Admin from "@/models/admin"; import Admin from "@/models/admin";
import * as Skeleton from "react-loading-skeleton"; import * as Skeleton from "react-loading-skeleton";
import "react-loading-skeleton/dist/skeleton.css"; import "react-loading-skeleton/dist/skeleton.css";
import paths from "@/utils/paths";
import { useNavigate } from "react-router-dom";
export default function WorkspaceAgentConfiguration({ workspace }) { export default function WorkspaceAgentConfiguration({ workspace }) {
const [settings, setSettings] = useState({}); const [settings, setSettings] = useState({});
const [hasChanges, setHasChanges] = useState(false); const [hasChanges, setHasChanges] = useState(false);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [agentSkills, setAgentSkills] = useState([]); const navigate = useNavigate();
const formEl = useRef(null); const formEl = useRef(null);
useEffect(() => { useEffect(() => {
async function fetchSettings() { async function fetchSettings() {
const _settings = await System.keys(); const _settings = await System.keys();
const _preferences = await Admin.systemPreferences(); const _preferences = await Admin.systemPreferences();
setSettings({ ..._settings, preferences: _preferences.settings } ?? {}); setSettings({ ..._settings, preferences: _preferences.settings } ?? {});
setAgentSkills(_preferences.settings?.default_agent_skills ?? []);
setLoading(false); setLoading(false);
} }
fetchSettings(); fetchSettings();
@ -73,14 +71,6 @@ export default function WorkspaceAgentConfiguration({ workspace }) {
setHasChanges(false); setHasChanges(false);
}; };
function toggleAgentSkill(skillName = "") {
setAgentSkills((prev) => {
return prev.includes(skillName)
? prev.filter((name) => name !== skillName)
: [...prev, skillName];
});
}
if (!workspace || loading) return <LoadingSkeleton />; if (!workspace || loading) return <LoadingSkeleton />;
return ( return (
<div id="workspace-agent-settings-container"> <div id="workspace-agent-settings-container">
@ -96,12 +86,23 @@ export default function WorkspaceAgentConfiguration({ workspace }) {
workspace={workspace} workspace={workspace}
setHasChanges={setHasChanges} setHasChanges={setHasChanges}
/> />
<AvailableAgentSkills {!hasChanges && (
skills={agentSkills} <div className="flex flex-col gap-y-4">
toggleAgentSkill={toggleAgentSkill} <button onClick={() => navigate(paths.settings.agentSkills())}>
settings={settings} <div
setHasChanges={setHasChanges} type="button"
/> className="w-fit transition-all duration-300 border border-slate-200 px-5 py-2.5 rounded-lg text-white text-sm items-center flex gap-x-2 hover:bg-slate-200 hover:text-slate-800 focus:ring-gray-800"
>
Configure Agent Skills
</div>
</button>
<p className="text-white text-opacity-60 text-xs font-medium">
Customize and enhance the default agent's capabilities by enabling
or disabling specific skills. These settings will be applied
across all workspaces.
</p>
</div>
)}
{hasChanges && ( {hasChanges && (
<button <button
type="submit" type="submit"
@ -143,83 +144,3 @@ function LoadingSkeleton() {
</div> </div>
); );
} }
function AvailableAgentSkills({
skills,
settings,
toggleAgentSkill,
setHasChanges,
}) {
return (
<div>
<div className="flex flex-col mb-8">
<div className="flex w-full justify-between items-center">
<label htmlFor="name" className="text-white text-md font-semibold">
Default agent skills
</label>
</div>
<p className="text-white text-opacity-60 text-xs font-medium py-1.5">
Improve the natural abilities of the default agent with these
pre-built skills. This set up applies to all workspaces.
</p>
</div>
<input
name="system::default_agent_skills"
type="hidden"
value={skills.join(",")}
/>
<div className="flex flex-col gap-y-3">
<GenericSkill
title="RAG & long-term memory"
description='Allow the agent to leverage your local documents to answer a query or ask the agent to "remember" pieces of content for long-term memory retrieval.'
settings={settings}
enabled={true}
disabled={true}
/>
<GenericSkill
title="View & summarize documents"
description="Allow the agent to list and summarize the content of workspace files currently embedded."
settings={settings}
enabled={true}
disabled={true}
/>
<GenericSkill
title="Scrape websites"
description="Allow the agent to visit and scrape the content of websites."
settings={settings}
enabled={true}
disabled={true}
/>
<GenericSkill
title="Generate charts"
description="Enable the default agent to generate various types of charts from data provided or given in chat."
skill="create-chart"
settings={settings}
toggleSkill={toggleAgentSkill}
enabled={skills.includes("create-chart")}
/>
<GenericSkill
title="Generate & save files to browser"
description="Enable the default agent to generate and write to files that save and can be downloaded in your browser."
skill="save-file-to-browser"
settings={settings}
toggleSkill={toggleAgentSkill}
enabled={skills.includes("save-file-to-browser")}
/>
<AgentWebSearchSelection
skill="web-browsing"
settings={settings}
toggleSkill={toggleAgentSkill}
enabled={skills.includes("web-browsing")}
/>
<AgentSQLConnectorSelection
skill="sql-agent"
settings={settings}
toggleSkill={toggleAgentSkill}
enabled={skills.includes("sql-agent")}
setHasChanges={setHasChanges}
/>
</div>
</div>
);
}

View File

@ -117,6 +117,9 @@ export default {
appearance: () => { appearance: () => {
return "/settings/appearance"; return "/settings/appearance";
}, },
agentSkills: () => {
return "/settings/agents";
},
apiKeys: () => { apiKeys: () => {
return "/settings/api-keys"; return "/settings/api-keys";
}, },

View File

@ -317,6 +317,7 @@ function adminEndpoints(app) {
[validatedRequest, flexUserRoleValid([ROLES.admin, ROLES.manager])], [validatedRequest, flexUserRoleValid([ROLES.admin, ROLES.manager])],
async (_, response) => { async (_, response) => {
try { try {
const embedder = getEmbeddingEngineSelection();
const settings = { const settings = {
users_can_delete_workspaces: users_can_delete_workspaces:
(await SystemSettings.get({ label: "users_can_delete_workspaces" })) (await SystemSettings.get({ label: "users_can_delete_workspaces" }))
@ -337,13 +338,12 @@ function adminEndpoints(app) {
text_splitter_chunk_size: text_splitter_chunk_size:
(await SystemSettings.get({ label: "text_splitter_chunk_size" })) (await SystemSettings.get({ label: "text_splitter_chunk_size" }))
?.value || ?.value ||
getEmbeddingEngineSelection()?.embeddingMaxChunkLength || embedder?.embeddingMaxChunkLength ||
null, null,
text_splitter_chunk_overlap: text_splitter_chunk_overlap:
(await SystemSettings.get({ label: "text_splitter_chunk_overlap" })) (await SystemSettings.get({ label: "text_splitter_chunk_overlap" }))
?.value || null, ?.value || null,
max_embed_chunk_size: max_embed_chunk_size: embedder?.embeddingMaxChunkLength || 1000,
getEmbeddingEngineSelection()?.embeddingMaxChunkLength || 1000,
agent_search_provider: agent_search_provider:
(await SystemSettings.get({ label: "agent_search_provider" })) (await SystemSettings.get({ label: "agent_search_provider" }))
?.value || null, ?.value || null,