Skip to content

Commit

Permalink
Add support for custom agent skills via plugins (#2202)
Browse files Browse the repository at this point in the history
* Add support for custom agent skills via plugins
Update Admin.systemPreferences to updated endpoint (legacy has deprecation notice

* lint

* dev build

* patch safeJson
patch label loading

* allow plugins with no config options

* lint

* catch invalid setupArgs in frontend

* update link to docs page for agent skills

* remove unneeded files

---------

Co-authored-by: shatfield4 <[email protected]>
  • Loading branch information
timothycarambat and shatfield4 authored Sep 11, 2024
1 parent f3f6299 commit d1103e2
Show file tree
Hide file tree
Showing 17 changed files with 768 additions and 30 deletions.
2 changes: 1 addition & 1 deletion .github/workflows/dev-build.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ concurrency:

on:
push:
branches: ['chrome-extension'] # put your current branch to create a build. Core team only.
branches: ['agent-skill-plugins'] # put your current branch to create a build. Core team only.
paths-ignore:
- '**.md'
- 'cloud-deployments/*'
Expand Down
22 changes: 22 additions & 0 deletions frontend/src/models/admin.js
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,8 @@ const Admin = {
},

// System Preferences
// TODO: remove this in favor of systemPreferencesByFields
// DEPRECATED: use systemPreferencesByFields instead
systemPreferences: async () => {
return await fetch(`${API_BASE}/admin/system-preferences`, {
method: "GET",
Expand All @@ -167,6 +169,26 @@ const Admin = {
return null;
});
},

/**
* Fetches system preferences by fields
* @param {string[]} labels - Array of labels for settings
* @returns {Promise<{settings: Object, error: string}>} - System preferences object
*/
systemPreferencesByFields: async (labels = []) => {
return await fetch(
`${API_BASE}/admin/system-preferences-for?labels=${labels.join(",")}`,
{
method: "GET",
headers: baseHeaders(),
}
)
.then((res) => res.json())
.catch((e) => {
console.error(e);
return null;
});
},
updateSystemPreferences: async (updates = {}) => {
return await fetch(`${API_BASE}/admin/system-preferences`, {
method: "POST",
Expand Down
43 changes: 43 additions & 0 deletions frontend/src/models/experimental/agentPlugins.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { API_BASE } from "@/utils/constants";
import { baseHeaders } from "@/utils/request";

const AgentPlugins = {
toggleFeature: async function (hubId, active = false) {
return await fetch(
`${API_BASE}/experimental/agent-plugins/${hubId}/toggle`,
{
method: "POST",
headers: baseHeaders(),
body: JSON.stringify({ active }),
}
)
.then((res) => {
if (!res.ok) throw new Error("Could not update agent plugin status.");
return true;
})
.catch((e) => {
console.error(e);
return false;
});
},
updatePluginConfig: async function (hubId, updates = {}) {
return await fetch(
`${API_BASE}/experimental/agent-plugins/${hubId}/config`,
{
method: "POST",
headers: baseHeaders(),
body: JSON.stringify({ updates }),
}
)
.then((res) => {
if (!res.ok) throw new Error("Could not update agent plugin config.");
return true;
})
.catch((e) => {
console.error(e);
return false;
});
},
};

export default AgentPlugins;
2 changes: 2 additions & 0 deletions frontend/src/models/system.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { API_BASE, AUTH_TIMESTAMP, fullApiUrl } from "@/utils/constants";
import { baseHeaders, safeJsonParse } from "@/utils/request";
import DataConnector from "./dataConnector";
import LiveDocumentSync from "./experimental/liveSync";
import AgentPlugins from "./experimental/agentPlugins";

const System = {
cacheKeys: {
Expand Down Expand Up @@ -675,6 +676,7 @@ const System = {
},
experimentalFeatures: {
liveSync: LiveDocumentSync,
agentPlugins: AgentPlugins,
},
};

Expand Down
180 changes: 180 additions & 0 deletions frontend/src/pages/Admin/Agents/Imported/ImportedSkillConfig/index.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
import System from "@/models/system";
import showToast from "@/utils/toast";
import { Plug } from "@phosphor-icons/react";
import { useEffect, useState } from "react";
import { sentenceCase } from "text-case";

/**
* Converts setup_args to inputs for the form builder
* @param {object} setupArgs - The setup arguments object
* @returns {object} - The inputs object
*/
function inputsFromArgs(setupArgs) {
if (
!setupArgs ||
setupArgs.constructor?.call?.().toString() !== "[object Object]"
) {
return {};
}
return Object.entries(setupArgs).reduce(
(acc, [key, props]) => ({
...acc,
[key]: props.hasOwnProperty("value")
? props.value
: props?.input?.default || "",
}),
{}
);
}

/**
* Imported skill config component for imported skills only.
* @returns {JSX.Element}
*/
export default function ImportedSkillConfig({
selectedSkill, // imported skill config object
setImportedSkills, // function to set imported skills since config is file-write
}) {
const [config, setConfig] = useState(selectedSkill);
const [hasChanges, setHasChanges] = useState(false);
const [inputs, setInputs] = useState(
inputsFromArgs(selectedSkill?.setup_args)
);

const hasSetupArgs =
selectedSkill?.setup_args &&
Object.keys(selectedSkill.setup_args).length > 0;

async function toggleSkill() {
const updatedConfig = { ...selectedSkill, active: !config.active };
await System.experimentalFeatures.agentPlugins.updatePluginConfig(
config.hubId,
{ active: !config.active }
);
setImportedSkills((prev) =>
prev.map((s) => (s.hubId === config.hubId ? updatedConfig : s))
);
setConfig(updatedConfig);
}

async function handleSubmit(e) {
e.preventDefault();
const errors = [];
const updatedConfig = { ...config };

for (const [key, value] of Object.entries(inputs)) {
const settings = config.setup_args[key];
if (settings.required && !value) {
errors.push(`${key} is required to have a value.`);
continue;
}
if (typeof value !== settings.type) {
errors.push(`${key} must be of type ${settings.type}.`);
continue;
}
updatedConfig.setup_args[key].value = value;
}

if (errors.length > 0) {
errors.forEach((error) => showToast(error, "error"));
return;
}

await System.experimentalFeatures.agentPlugins.updatePluginConfig(
config.hubId,
updatedConfig
);
setConfig(updatedConfig);
setImportedSkills((prev) =>
prev.map((skill) =>
skill.hubId === config.hubId ? updatedConfig : skill
)
);
showToast("Skill config updated successfully.", "success");
}

useEffect(() => {
setHasChanges(
JSON.stringify(inputs) !==
JSON.stringify(inputsFromArgs(selectedSkill.setup_args))
);
}, [inputs]);

return (
<>
<div className="p-2">
<div className="flex flex-col gap-y-[18px] max-w-[500px]">
<div className="flex items-center gap-x-2">
<Plug size={24} color="white" weight="bold" />
<label htmlFor="name" className="text-white text-md font-bold">
{sentenceCase(config.name)}
</label>
<label className="border-none relative inline-flex cursor-pointer items-center ml-auto">
<input
type="checkbox"
className="peer sr-only"
checked={config.active}
onChange={() => toggleSkill()}
/>
<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">
{config.description} by{" "}
<a
href={config.author_url}
target="_blank"
rel="noopener noreferrer"
className="text-white hover:underline"
>
{config.author}
</a>
</p>

{hasSetupArgs ? (
<div className="flex flex-col gap-y-2">
{Object.entries(config.setup_args).map(([key, props]) => (
<div key={key} className="flex flex-col gap-y-1">
<label htmlFor={key} className="text-white text-sm font-bold">
{key}
</label>
<input
type={props?.input?.type || "text"}
required={props?.input?.required}
defaultValue={
props.hasOwnProperty("value")
? props.value
: props?.input?.default || ""
}
onChange={(e) =>
setInputs({ ...inputs, [key]: e.target.value })
}
placeholder={props?.input?.placeholder || ""}
className="bg-transparent border border-white border-opacity-20 rounded-md p-2 text-white text-sm"
/>
<p className="text-white text-opacity-60 text-xs font-medium py-1.5">
{props?.input?.hint}
</p>
</div>
))}
{hasChanges && (
<button
onClick={handleSubmit}
type="button"
className="bg-blue-500 text-white rounded-md p-2"
>
Save
</button>
)}
</div>
) : (
<p className="text-white text-opacity-60 text-sm font-medium py-1.5">
There are no options to modify for this skill.
</p>
)}
</div>
</div>
</>
);
}
59 changes: 59 additions & 0 deletions frontend/src/pages/Admin/Agents/Imported/SkillList/index.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { CaretRight } from "@phosphor-icons/react";
import { isMobile } from "react-device-detect";
import { sentenceCase } from "text-case";

export default function ImportedSkillList({
skills = [],
selectedSkill = null,
handleClick = null,
}) {
if (skills.length === 0)
return (
<div className="text-white/60 text-center text-xs flex flex-col gap-y-2">
<p>No imported skills found</p>
<p>
Learn about agent skills in the{" "}
<a
href="https://docs.anythingllm.com/agent/custom/developer-guide"
target="_blank"
className="text-white/80 hover:underline"
>
AnythingLLM Agent Docs
</a>
.
</p>
</div>
);

return (
<div
className={`bg-white/5 text-white rounded-xl ${
isMobile ? "w-full" : "min-w-[360px] w-fit"
}`}
>
{skills.map((config, index) => (
<div
key={config.hubId}
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 === config.hubId ? "bg-white/10" : ""
}`}
onClick={() => handleClick?.({ ...config, imported: true })}
>
<div className="text-sm font-light">{sentenceCase(config.name)}</div>
<div className="flex items-center gap-x-2">
<div className="text-sm text-white/60 font-medium">
{config.active ? "On" : "Off"}
</div>
<CaretRight size={14} weight="bold" className="text-white/80" />
</div>
</div>
))}
</div>
);
}
Loading

0 comments on commit d1103e2

Please sign in to comment.