Skip to content

Commit d1103e2

Browse files
Add support for custom agent skills via plugins (#2202)
* 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]>
1 parent f3f6299 commit d1103e2

File tree

17 files changed

+768
-30
lines changed

17 files changed

+768
-30
lines changed

.github/workflows/dev-build.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ concurrency:
66

77
on:
88
push:
9-
branches: ['chrome-extension'] # put your current branch to create a build. Core team only.
9+
branches: ['agent-skill-plugins'] # put your current branch to create a build. Core team only.
1010
paths-ignore:
1111
- '**.md'
1212
- 'cloud-deployments/*'

frontend/src/models/admin.js

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,8 @@ const Admin = {
156156
},
157157

158158
// System Preferences
159+
// TODO: remove this in favor of systemPreferencesByFields
160+
// DEPRECATED: use systemPreferencesByFields instead
159161
systemPreferences: async () => {
160162
return await fetch(`${API_BASE}/admin/system-preferences`, {
161163
method: "GET",
@@ -167,6 +169,26 @@ const Admin = {
167169
return null;
168170
});
169171
},
172+
173+
/**
174+
* Fetches system preferences by fields
175+
* @param {string[]} labels - Array of labels for settings
176+
* @returns {Promise<{settings: Object, error: string}>} - System preferences object
177+
*/
178+
systemPreferencesByFields: async (labels = []) => {
179+
return await fetch(
180+
`${API_BASE}/admin/system-preferences-for?labels=${labels.join(",")}`,
181+
{
182+
method: "GET",
183+
headers: baseHeaders(),
184+
}
185+
)
186+
.then((res) => res.json())
187+
.catch((e) => {
188+
console.error(e);
189+
return null;
190+
});
191+
},
170192
updateSystemPreferences: async (updates = {}) => {
171193
return await fetch(`${API_BASE}/admin/system-preferences`, {
172194
method: "POST",
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import { API_BASE } from "@/utils/constants";
2+
import { baseHeaders } from "@/utils/request";
3+
4+
const AgentPlugins = {
5+
toggleFeature: async function (hubId, active = false) {
6+
return await fetch(
7+
`${API_BASE}/experimental/agent-plugins/${hubId}/toggle`,
8+
{
9+
method: "POST",
10+
headers: baseHeaders(),
11+
body: JSON.stringify({ active }),
12+
}
13+
)
14+
.then((res) => {
15+
if (!res.ok) throw new Error("Could not update agent plugin status.");
16+
return true;
17+
})
18+
.catch((e) => {
19+
console.error(e);
20+
return false;
21+
});
22+
},
23+
updatePluginConfig: async function (hubId, updates = {}) {
24+
return await fetch(
25+
`${API_BASE}/experimental/agent-plugins/${hubId}/config`,
26+
{
27+
method: "POST",
28+
headers: baseHeaders(),
29+
body: JSON.stringify({ updates }),
30+
}
31+
)
32+
.then((res) => {
33+
if (!res.ok) throw new Error("Could not update agent plugin config.");
34+
return true;
35+
})
36+
.catch((e) => {
37+
console.error(e);
38+
return false;
39+
});
40+
},
41+
};
42+
43+
export default AgentPlugins;

frontend/src/models/system.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { API_BASE, AUTH_TIMESTAMP, fullApiUrl } from "@/utils/constants";
22
import { baseHeaders, safeJsonParse } from "@/utils/request";
33
import DataConnector from "./dataConnector";
44
import LiveDocumentSync from "./experimental/liveSync";
5+
import AgentPlugins from "./experimental/agentPlugins";
56

67
const System = {
78
cacheKeys: {
@@ -675,6 +676,7 @@ const System = {
675676
},
676677
experimentalFeatures: {
677678
liveSync: LiveDocumentSync,
679+
agentPlugins: AgentPlugins,
678680
},
679681
};
680682

Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,180 @@
1+
import System from "@/models/system";
2+
import showToast from "@/utils/toast";
3+
import { Plug } from "@phosphor-icons/react";
4+
import { useEffect, useState } from "react";
5+
import { sentenceCase } from "text-case";
6+
7+
/**
8+
* Converts setup_args to inputs for the form builder
9+
* @param {object} setupArgs - The setup arguments object
10+
* @returns {object} - The inputs object
11+
*/
12+
function inputsFromArgs(setupArgs) {
13+
if (
14+
!setupArgs ||
15+
setupArgs.constructor?.call?.().toString() !== "[object Object]"
16+
) {
17+
return {};
18+
}
19+
return Object.entries(setupArgs).reduce(
20+
(acc, [key, props]) => ({
21+
...acc,
22+
[key]: props.hasOwnProperty("value")
23+
? props.value
24+
: props?.input?.default || "",
25+
}),
26+
{}
27+
);
28+
}
29+
30+
/**
31+
* Imported skill config component for imported skills only.
32+
* @returns {JSX.Element}
33+
*/
34+
export default function ImportedSkillConfig({
35+
selectedSkill, // imported skill config object
36+
setImportedSkills, // function to set imported skills since config is file-write
37+
}) {
38+
const [config, setConfig] = useState(selectedSkill);
39+
const [hasChanges, setHasChanges] = useState(false);
40+
const [inputs, setInputs] = useState(
41+
inputsFromArgs(selectedSkill?.setup_args)
42+
);
43+
44+
const hasSetupArgs =
45+
selectedSkill?.setup_args &&
46+
Object.keys(selectedSkill.setup_args).length > 0;
47+
48+
async function toggleSkill() {
49+
const updatedConfig = { ...selectedSkill, active: !config.active };
50+
await System.experimentalFeatures.agentPlugins.updatePluginConfig(
51+
config.hubId,
52+
{ active: !config.active }
53+
);
54+
setImportedSkills((prev) =>
55+
prev.map((s) => (s.hubId === config.hubId ? updatedConfig : s))
56+
);
57+
setConfig(updatedConfig);
58+
}
59+
60+
async function handleSubmit(e) {
61+
e.preventDefault();
62+
const errors = [];
63+
const updatedConfig = { ...config };
64+
65+
for (const [key, value] of Object.entries(inputs)) {
66+
const settings = config.setup_args[key];
67+
if (settings.required && !value) {
68+
errors.push(`${key} is required to have a value.`);
69+
continue;
70+
}
71+
if (typeof value !== settings.type) {
72+
errors.push(`${key} must be of type ${settings.type}.`);
73+
continue;
74+
}
75+
updatedConfig.setup_args[key].value = value;
76+
}
77+
78+
if (errors.length > 0) {
79+
errors.forEach((error) => showToast(error, "error"));
80+
return;
81+
}
82+
83+
await System.experimentalFeatures.agentPlugins.updatePluginConfig(
84+
config.hubId,
85+
updatedConfig
86+
);
87+
setConfig(updatedConfig);
88+
setImportedSkills((prev) =>
89+
prev.map((skill) =>
90+
skill.hubId === config.hubId ? updatedConfig : skill
91+
)
92+
);
93+
showToast("Skill config updated successfully.", "success");
94+
}
95+
96+
useEffect(() => {
97+
setHasChanges(
98+
JSON.stringify(inputs) !==
99+
JSON.stringify(inputsFromArgs(selectedSkill.setup_args))
100+
);
101+
}, [inputs]);
102+
103+
return (
104+
<>
105+
<div className="p-2">
106+
<div className="flex flex-col gap-y-[18px] max-w-[500px]">
107+
<div className="flex items-center gap-x-2">
108+
<Plug size={24} color="white" weight="bold" />
109+
<label htmlFor="name" className="text-white text-md font-bold">
110+
{sentenceCase(config.name)}
111+
</label>
112+
<label className="border-none relative inline-flex cursor-pointer items-center ml-auto">
113+
<input
114+
type="checkbox"
115+
className="peer sr-only"
116+
checked={config.active}
117+
onChange={() => toggleSkill()}
118+
/>
119+
<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>
120+
<span className="ml-3 text-sm font-medium text-gray-900 dark:text-gray-300"></span>
121+
</label>
122+
</div>
123+
<p className="text-white text-opacity-60 text-xs font-medium py-1.5">
124+
{config.description} by{" "}
125+
<a
126+
href={config.author_url}
127+
target="_blank"
128+
rel="noopener noreferrer"
129+
className="text-white hover:underline"
130+
>
131+
{config.author}
132+
</a>
133+
</p>
134+
135+
{hasSetupArgs ? (
136+
<div className="flex flex-col gap-y-2">
137+
{Object.entries(config.setup_args).map(([key, props]) => (
138+
<div key={key} className="flex flex-col gap-y-1">
139+
<label htmlFor={key} className="text-white text-sm font-bold">
140+
{key}
141+
</label>
142+
<input
143+
type={props?.input?.type || "text"}
144+
required={props?.input?.required}
145+
defaultValue={
146+
props.hasOwnProperty("value")
147+
? props.value
148+
: props?.input?.default || ""
149+
}
150+
onChange={(e) =>
151+
setInputs({ ...inputs, [key]: e.target.value })
152+
}
153+
placeholder={props?.input?.placeholder || ""}
154+
className="bg-transparent border border-white border-opacity-20 rounded-md p-2 text-white text-sm"
155+
/>
156+
<p className="text-white text-opacity-60 text-xs font-medium py-1.5">
157+
{props?.input?.hint}
158+
</p>
159+
</div>
160+
))}
161+
{hasChanges && (
162+
<button
163+
onClick={handleSubmit}
164+
type="button"
165+
className="bg-blue-500 text-white rounded-md p-2"
166+
>
167+
Save
168+
</button>
169+
)}
170+
</div>
171+
) : (
172+
<p className="text-white text-opacity-60 text-sm font-medium py-1.5">
173+
There are no options to modify for this skill.
174+
</p>
175+
)}
176+
</div>
177+
</div>
178+
</>
179+
);
180+
}
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import { CaretRight } from "@phosphor-icons/react";
2+
import { isMobile } from "react-device-detect";
3+
import { sentenceCase } from "text-case";
4+
5+
export default function ImportedSkillList({
6+
skills = [],
7+
selectedSkill = null,
8+
handleClick = null,
9+
}) {
10+
if (skills.length === 0)
11+
return (
12+
<div className="text-white/60 text-center text-xs flex flex-col gap-y-2">
13+
<p>No imported skills found</p>
14+
<p>
15+
Learn about agent skills in the{" "}
16+
<a
17+
href="https://docs.anythingllm.com/agent/custom/developer-guide"
18+
target="_blank"
19+
className="text-white/80 hover:underline"
20+
>
21+
AnythingLLM Agent Docs
22+
</a>
23+
.
24+
</p>
25+
</div>
26+
);
27+
28+
return (
29+
<div
30+
className={`bg-white/5 text-white rounded-xl ${
31+
isMobile ? "w-full" : "min-w-[360px] w-fit"
32+
}`}
33+
>
34+
{skills.map((config, index) => (
35+
<div
36+
key={config.hubId}
37+
className={`py-3 px-4 flex items-center justify-between ${
38+
index === 0 ? "rounded-t-xl" : ""
39+
} ${
40+
index === Object.keys(skills).length - 1
41+
? "rounded-b-xl"
42+
: "border-b border-white/10"
43+
} cursor-pointer transition-all duration-300 hover:bg-white/5 ${
44+
selectedSkill === config.hubId ? "bg-white/10" : ""
45+
}`}
46+
onClick={() => handleClick?.({ ...config, imported: true })}
47+
>
48+
<div className="text-sm font-light">{sentenceCase(config.name)}</div>
49+
<div className="flex items-center gap-x-2">
50+
<div className="text-sm text-white/60 font-medium">
51+
{config.active ? "On" : "Off"}
52+
</div>
53+
<CaretRight size={14} weight="bold" className="text-white/80" />
54+
</div>
55+
</div>
56+
))}
57+
</div>
58+
);
59+
}

0 commit comments

Comments
 (0)