diff --git a/.github/workflows/dev-build.yaml b/.github/workflows/dev-build.yaml index 6d938af8ee9d0e1c55e973a1de6685206ce5c3ac..86e10bbcd98c1952ccd2f8ef15ac0956a1fb8d0d 100644 --- a/.github/workflows/dev-build.yaml +++ b/.github/workflows/dev-build.yaml @@ -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/*' diff --git a/frontend/src/models/admin.js b/frontend/src/models/admin.js index c8fe2cc9786eaa22354f257505b8bcde6001cc38..336e98789b43defb67f0b5451f8525959b9d6b66 100644 --- a/frontend/src/models/admin.js +++ b/frontend/src/models/admin.js @@ -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", @@ -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", diff --git a/frontend/src/models/experimental/agentPlugins.js b/frontend/src/models/experimental/agentPlugins.js new file mode 100644 index 0000000000000000000000000000000000000000..9a544d5f12f4d06cacdda594335fbd328f7eab8b --- /dev/null +++ b/frontend/src/models/experimental/agentPlugins.js @@ -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; diff --git a/frontend/src/models/system.js b/frontend/src/models/system.js index 095244a4858d5aefd39fee9c12e9478d9cba0264..cb2f34e023c24725dc4b7dec8de63f291300d5a5 100644 --- a/frontend/src/models/system.js +++ b/frontend/src/models/system.js @@ -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: { @@ -675,6 +676,7 @@ const System = { }, experimentalFeatures: { liveSync: LiveDocumentSync, + agentPlugins: AgentPlugins, }, }; diff --git a/frontend/src/pages/Admin/Agents/Imported/ImportedSkillConfig/index.jsx b/frontend/src/pages/Admin/Agents/Imported/ImportedSkillConfig/index.jsx new file mode 100644 index 0000000000000000000000000000000000000000..644d931d26bfb3759083b004ec4590d0bd5ba92c --- /dev/null +++ b/frontend/src/pages/Admin/Agents/Imported/ImportedSkillConfig/index.jsx @@ -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> + </> + ); +} diff --git a/frontend/src/pages/Admin/Agents/Imported/SkillList/index.jsx b/frontend/src/pages/Admin/Agents/Imported/SkillList/index.jsx new file mode 100644 index 0000000000000000000000000000000000000000..b6358077ac9e55f3e08d893819d977737eb70b2a --- /dev/null +++ b/frontend/src/pages/Admin/Agents/Imported/SkillList/index.jsx @@ -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> + ); +} diff --git a/frontend/src/pages/Admin/Agents/index.jsx b/frontend/src/pages/Admin/Agents/index.jsx index 9cb1a93ae7161aedbda907daa4d29620dc513640..99c093d5cb27bce6d97dba6f2ac401e389d3e09c 100644 --- a/frontend/src/pages/Admin/Agents/index.jsx +++ b/frontend/src/pages/Admin/Agents/index.jsx @@ -4,18 +4,21 @@ import { isMobile } from "react-device-detect"; import Admin from "@/models/admin"; import System from "@/models/system"; import showToast from "@/utils/toast"; -import { CaretLeft, CaretRight, Robot } from "@phosphor-icons/react"; +import { CaretLeft, CaretRight, Plug, 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"; +import ImportedSkillList from "./Imported/SkillList"; +import ImportedSkillConfig from "./Imported/ImportedSkillConfig"; export default function AdminAgents() { const [hasChanges, setHasChanges] = useState(false); const [settings, setSettings] = useState({}); const [selectedSkill, setSelectedSkill] = useState(""); const [agentSkills, setAgentSkills] = useState([]); + const [importedSkills, setImportedSkills] = useState([]); const [loading, setLoading] = useState(true); const [showSkillModal, setShowSkillModal] = useState(false); const formEl = useRef(null); @@ -37,9 +40,13 @@ export default function AdminAgents() { useEffect(() => { async function fetchSettings() { const _settings = await System.keys(); - const _preferences = await Admin.systemPreferences(); + const _preferences = await Admin.systemPreferencesByFields([ + "default_agent_skills", + "imported_agent_skills", + ]); setSettings({ ..._settings, preferences: _preferences.settings } ?? {}); setAgentSkills(_preferences.settings?.default_agent_skills ?? []); + setImportedSkills(_preferences.settings?.imported_agent_skills ?? []); setLoading(false); } fetchSettings(); @@ -84,9 +91,13 @@ export default function AdminAgents() { if (success) { const _settings = await System.keys(); - const _preferences = await Admin.systemPreferences(); + const _preferences = await Admin.systemPreferencesByFields([ + "default_agent_skills", + "imported_agent_skills", + ]); setSettings({ ..._settings, preferences: _preferences.settings } ?? {}); setAgentSkills(_preferences.settings?.default_agent_skills ?? []); + setImportedSkills(_preferences.settings?.imported_agent_skills ?? []); showToast(`Agent preferences saved successfully.`, "success", { clear: true, }); @@ -97,9 +108,10 @@ export default function AdminAgents() { setHasChanges(false); }; - const SelectedSkillComponent = - configurableSkills[selectedSkill]?.component || - defaultSkills[selectedSkill]?.component; + const SelectedSkillComponent = selectedSkill.imported + ? ImportedSkillConfig + : configurableSkills[selectedSkill]?.component || + defaultSkills[selectedSkill]?.component; if (loading) { return ( @@ -157,6 +169,16 @@ export default function AdminAgents() { }} activeSkills={agentSkills} /> + + <div className="text-white flex items-center gap-x-2"> + <Plug size={24} /> + <p className="text-lg font-medium">Custom Skills</p> + </div> + <ImportedSkillList + skills={importedSkills} + selectedSkill={selectedSkill} + handleClick={setSelectedSkill} + /> </div> {/* Selected agent skill modal */} @@ -181,17 +203,27 @@ export default function AdminAgents() { <div className="flex-1 overflow-y-auto p-4"> <div className="bg-[#303237] text-white rounded-xl p-4"> {SelectedSkillComponent ? ( - <SelectedSkillComponent - skill={configurableSkills[selectedSkill]?.skill} - settings={settings} - toggleSkill={toggleAgentSkill} - enabled={agentSkills.includes( - configurableSkills[selectedSkill]?.skill + <> + {selectedSkill.imported ? ( + <ImportedSkillConfig + key={selectedSkill.hubId} + selectedSkill={selectedSkill} + setImportedSkills={setImportedSkills} + /> + ) : ( + <SelectedSkillComponent + skill={configurableSkills[selectedSkill]?.skill} + settings={settings} + toggleSkill={toggleAgentSkill} + enabled={agentSkills.includes( + configurableSkills[selectedSkill]?.skill + )} + setHasChanges={setHasChanges} + {...(configurableSkills[selectedSkill] || + defaultSkills[selectedSkill])} + /> )} - setHasChanges={setHasChanges} - {...(configurableSkills[selectedSkill] || - defaultSkills[selectedSkill])} - /> + </> ) : ( <div className="flex flex-col items-center justify-center h-full text-white/60"> <Robot size={40} /> @@ -216,7 +248,7 @@ export default function AdminAgents() { > <form onSubmit={handleSubmit} - onChange={() => setHasChanges(true)} + onChange={() => !selectedSkill.imported && setHasChanges(true)} ref={formEl} className="flex-1 flex gap-x-6 p-4 mt-10" > @@ -247,23 +279,43 @@ export default function AdminAgents() { handleClick={setSelectedSkill} activeSkills={agentSkills} /> + + <div className="text-white flex items-center gap-x-2"> + <Plug size={24} /> + <p className="text-lg font-medium">Custom Skills</p> + </div> + <ImportedSkillList + skills={importedSkills} + selectedSkill={selectedSkill} + handleClick={setSelectedSkill} + /> </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 + <> + {selectedSkill.imported ? ( + <ImportedSkillConfig + key={selectedSkill.hubId} + selectedSkill={selectedSkill} + setImportedSkills={setImportedSkills} + /> + ) : ( + <SelectedSkillComponent + skill={configurableSkills[selectedSkill]?.skill} + settings={settings} + toggleSkill={toggleAgentSkill} + enabled={agentSkills.includes( + configurableSkills[selectedSkill]?.skill + )} + setHasChanges={setHasChanges} + {...(configurableSkills[selectedSkill] || + defaultSkills[selectedSkill])} + /> )} - setHasChanges={setHasChanges} - {...(configurableSkills[selectedSkill] || - defaultSkills[selectedSkill])} - /> + </> ) : ( <div className="flex flex-col items-center justify-center h-full text-white/60"> <Robot size={40} /> diff --git a/server/.gitignore b/server/.gitignore index adcf7aa4b5be49a167eddc502e650c9134c69bdd..e78e20b97ea2281b4d79a0dab5086bf946358bd0 100644 --- a/server/.gitignore +++ b/server/.gitignore @@ -8,6 +8,7 @@ storage/tmp/* storage/vector-cache/*.json storage/exports storage/imports +storage/plugins/agent-skills/* !storage/documents/DOCUMENTS.md logs/server.log *.db diff --git a/server/endpoints/admin.js b/server/endpoints/admin.js index 457d7567b95da0fe1536bd7e09deec7bbac5a9af..994c8e41654bd7594dacae08bd1d15cc1030d1e7 100644 --- a/server/endpoints/admin.js +++ b/server/endpoints/admin.js @@ -24,6 +24,7 @@ const { ROLES, } = require("../utils/middleware/multiUserProtected"); const { validatedRequest } = require("../utils/middleware/validatedRequest"); +const ImportedPlugin = require("../utils/agents/imported"); function adminEndpoints(app) { if (!app) return; @@ -311,7 +312,109 @@ function adminEndpoints(app) { } ); - // TODO: Allow specification of which props to get instead of returning all of them all the time. + // System preferences but only by array of labels + app.get( + "/admin/system-preferences-for", + [validatedRequest, flexUserRoleValid([ROLES.admin, ROLES.manager])], + async (request, response) => { + try { + const requestedSettings = {}; + const labels = request.query.labels?.split(",") || []; + const needEmbedder = [ + "text_splitter_chunk_size", + "max_embed_chunk_size", + ]; + const noRecord = [ + "max_embed_chunk_size", + "agent_sql_connections", + "imported_agent_skills", + "feature_flags", + "meta_page_title", + "meta_page_favicon", + ]; + + for (const label of labels) { + // Skip any settings that are not explicitly defined as public + if (!SystemSettings.publicFields.includes(label)) continue; + + // Only get the embedder if the setting actually needs it + let embedder = needEmbedder.includes(label) + ? getEmbeddingEngineSelection() + : null; + // Only get the record from db if the setting actually needs it + let setting = noRecord.includes(label) + ? null + : await SystemSettings.get({ label }); + + switch (label) { + case "limit_user_messages": + requestedSettings[label] = setting?.value === "true"; + break; + case "message_limit": + requestedSettings[label] = setting?.value + ? Number(setting.value) + : 10; + break; + case "footer_data": + requestedSettings[label] = setting?.value ?? JSON.stringify([]); + break; + case "support_email": + requestedSettings[label] = setting?.value || null; + break; + case "text_splitter_chunk_size": + requestedSettings[label] = + setting?.value || embedder?.embeddingMaxChunkLength || null; + break; + case "text_splitter_chunk_overlap": + requestedSettings[label] = setting?.value || null; + break; + case "max_embed_chunk_size": + requestedSettings[label] = + embedder?.embeddingMaxChunkLength || 1000; + break; + case "agent_search_provider": + requestedSettings[label] = setting?.value || null; + break; + case "agent_sql_connections": + requestedSettings[label] = + await SystemSettings.brief.agent_sql_connections(); + break; + case "default_agent_skills": + requestedSettings[label] = safeJsonParse(setting?.value, []); + break; + case "imported_agent_skills": + requestedSettings[label] = ImportedPlugin.listImportedPlugins(); + break; + case "custom_app_name": + requestedSettings[label] = setting?.value || null; + break; + case "feature_flags": + requestedSettings[label] = + (await SystemSettings.getFeatureFlags()) || {}; + break; + case "meta_page_title": + requestedSettings[label] = + await SystemSettings.getValueOrFallback({ label }, null); + break; + case "meta_page_favicon": + requestedSettings[label] = + await SystemSettings.getValueOrFallback({ label }, null); + break; + default: + break; + } + } + + response.status(200).json({ settings: requestedSettings }); + } catch (e) { + console.error(e); + response.sendStatus(500).end(); + } + } + ); + + // TODO: Delete this endpoint + // DEPRECATED - use /admin/system-preferences-for instead with ?labels=... comma separated string of labels app.get( "/admin/system-preferences", [validatedRequest, flexUserRoleValid([ROLES.admin, ROLES.manager])], @@ -352,6 +455,7 @@ function adminEndpoints(app) { ?.value, [] ) || [], + imported_agent_skills: ImportedPlugin.listImportedPlugins(), custom_app_name: (await SystemSettings.get({ label: "custom_app_name" }))?.value || null, diff --git a/server/endpoints/experimental/imported-agent-plugins.js b/server/endpoints/experimental/imported-agent-plugins.js new file mode 100644 index 0000000000000000000000000000000000000000..cdc0148cb2f16eae0fe86636b3ebb01e97a3ca16 --- /dev/null +++ b/server/endpoints/experimental/imported-agent-plugins.js @@ -0,0 +1,50 @@ +const ImportedPlugin = require("../../utils/agents/imported"); +const { reqBody } = require("../../utils/http"); +const { + flexUserRoleValid, + ROLES, +} = require("../../utils/middleware/multiUserProtected"); +const { validatedRequest } = require("../../utils/middleware/validatedRequest"); + +function importedAgentPluginEndpoints(app) { + if (!app) return; + + app.post( + "/experimental/agent-plugins/:hubId/toggle", + [validatedRequest, flexUserRoleValid([ROLES.admin])], + (request, response) => { + try { + const { hubId } = request.params; + const { active } = reqBody(request); + const updatedConfig = ImportedPlugin.updateImportedPlugin(hubId, { + active: Boolean(active), + }); + response.status(200).json(updatedConfig); + } catch (e) { + console.error(e); + response.status(500).end(); + } + } + ); + + app.post( + "/experimental/agent-plugins/:hubId/config", + [validatedRequest, flexUserRoleValid([ROLES.admin])], + (request, response) => { + try { + const { hubId } = request.params; + const { updates } = reqBody(request); + const updatedConfig = ImportedPlugin.updateImportedPlugin( + hubId, + updates + ); + response.status(200).json(updatedConfig); + } catch (e) { + console.error(e); + response.status(500).end(); + } + } + ); +} + +module.exports = { importedAgentPluginEndpoints }; diff --git a/server/endpoints/experimental/index.js b/server/endpoints/experimental/index.js index e7dd144c5b0636ef868c62513fbce0cfdde84629..cc390811a9b4a340aaaa77fa5b17aa2d5ee89b0d 100644 --- a/server/endpoints/experimental/index.js +++ b/server/endpoints/experimental/index.js @@ -1,5 +1,6 @@ const { fineTuningEndpoints } = require("./fineTuning"); const { liveSyncEndpoints } = require("./liveSync"); +const { importedAgentPluginEndpoints } = require("./imported-agent-plugins"); // All endpoints here are not stable and can move around - have breaking changes // or are opt-in features that are not fully released. @@ -7,6 +8,7 @@ const { liveSyncEndpoints } = require("./liveSync"); function experimentalEndpoints(router) { liveSyncEndpoints(router); fineTuningEndpoints(router); + importedAgentPluginEndpoints(router); } module.exports = { experimentalEndpoints }; diff --git a/server/models/systemSettings.js b/server/models/systemSettings.js index e9ae3f3e9613fae591bd87dcda8071b8ceda0857..c2c03ffa0996e1e659c5b9b3163eadcba75bac7c 100644 --- a/server/models/systemSettings.js +++ b/server/models/systemSettings.js @@ -15,6 +15,23 @@ function isNullOrNaN(value) { const SystemSettings = { protectedFields: ["multi_user_mode"], + publicFields: [ + "limit_user_messages", + "message_limit", + "footer_data", + "support_email", + "text_splitter_chunk_size", + "text_splitter_chunk_overlap", + "max_embed_chunk_size", + "agent_search_provider", + "agent_sql_connections", + "default_agent_skills", + "imported_agent_skills", + "custom_app_name", + "feature_flags", + "meta_page_title", + "meta_page_favicon", + ], supportedFields: [ "limit_user_messages", "message_limit", diff --git a/server/utils/agents/aibitat/index.js b/server/utils/agents/aibitat/index.js index 0d3aab1add4940fa1df43c3bfa68a44f77048b86..56da25eb1dd63cd8f0abae6753595c31fab90c08 100644 --- a/server/utils/agents/aibitat/index.js +++ b/server/utils/agents/aibitat/index.js @@ -504,9 +504,13 @@ Only return the role. * @param {string} pluginName this name of the plugin being called * @returns string of the plugin to be called compensating for children denoted by # in the string. * eg: sql-agent:list-database-connections + * or is a custom plugin + * eg: @@custom-plugin-name */ #parseFunctionName(pluginName = "") { - if (!pluginName.includes("#")) return pluginName; + if (!pluginName.includes("#") && !pluginName.startsWith("@@")) + return pluginName; + if (pluginName.startsWith("@@")) return pluginName.replace("@@", ""); return pluginName.split("#")[1]; } diff --git a/server/utils/agents/defaults.js b/server/utils/agents/defaults.js index a6d30ca15b17c7fcbf3ce41d24c1bb7f43693906..6154fab66728de590551b0f208b4fdcb826e92df 100644 --- a/server/utils/agents/defaults.js +++ b/server/utils/agents/defaults.js @@ -2,6 +2,7 @@ const AgentPlugins = require("./aibitat/plugins"); const { SystemSettings } = require("../../models/systemSettings"); const { safeJsonParse } = require("../http"); const Provider = require("./aibitat/providers/ai-provider"); +const ImportedPlugin = require("./imported"); const USER_AGENT = { name: "USER", @@ -27,6 +28,7 @@ const WORKSPACE_AGENT = { functions: [ ...defaultFunctions, ...(await agentSkillsFromSystemSettings()), + ...(await ImportedPlugin.activeImportedPlugins()), ], }; }, diff --git a/server/utils/agents/imported.js b/server/utils/agents/imported.js new file mode 100644 index 0000000000000000000000000000000000000000..136f4a3ad1d619c0988d63aaec2cc91dd9044aa7 --- /dev/null +++ b/server/utils/agents/imported.js @@ -0,0 +1,176 @@ +const fs = require("fs"); +const path = require("path"); +const { safeJsonParse } = require("../http"); +const { isWithin, normalizePath } = require("../files"); +const pluginsPath = + process.env.NODE_ENV === "development" + ? path.resolve(__dirname, "../../storage/plugins/agent-skills") + : path.resolve(process.env.STORAGE_DIR, "plugins", "agent-skills"); + +class ImportedPlugin { + constructor(config) { + this.config = config; + this.handlerLocation = path.resolve( + pluginsPath, + this.config.hubId, + "handler.js" + ); + delete require.cache[require.resolve(this.handlerLocation)]; + this.handler = require(this.handlerLocation); + this.name = config.hubId; + this.startupConfig = { + params: {}, + }; + } + + /** + * Gets the imported plugin handler. + * @param {string} hubId - The hub ID of the plugin. + * @returns {ImportedPlugin} - The plugin handler. + */ + static loadPluginByHubId(hubId) { + const configLocation = path.resolve( + pluginsPath, + normalizePath(hubId), + "plugin.json" + ); + if (!this.isValidLocation(configLocation)) return; + const config = safeJsonParse(fs.readFileSync(configLocation, "utf8")); + return new ImportedPlugin(config); + } + + static isValidLocation(pathToValidate) { + if (!isWithin(pluginsPath, pathToValidate)) return false; + if (!fs.existsSync(pathToValidate)) return false; + return true; + } + + /** + * 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. + */ + static async activeImportedPlugins() { + const plugins = []; + const folders = fs.readdirSync(path.resolve(pluginsPath)); + for (const folder of folders) { + const configLocation = path.resolve( + pluginsPath, + normalizePath(folder), + "plugin.json" + ); + if (!this.isValidLocation(configLocation)) continue; + const config = safeJsonParse(fs.readFileSync(configLocation, "utf8")); + if (config.active) plugins.push(`@@${config.hubId}`); + } + return plugins; + } + + /** + * Lists all imported plugins. + * @returns {Array} - array of plugin configurations (JSON). + */ + static listImportedPlugins() { + const plugins = []; + if (!fs.existsSync(pluginsPath)) return plugins; + + const folders = fs.readdirSync(path.resolve(pluginsPath)); + for (const folder of folders) { + const configLocation = path.resolve( + pluginsPath, + normalizePath(folder), + "plugin.json" + ); + if (!this.isValidLocation(configLocation)) continue; + const config = safeJsonParse(fs.readFileSync(configLocation, "utf8")); + plugins.push(config); + } + return plugins; + } + + /** + * Updates a plugin configuration. + * @param {string} hubId - The hub ID of the plugin. + * @param {object} config - The configuration to update. + * @returns {object} - The updated configuration. + */ + static updateImportedPlugin(hubId, config) { + const configLocation = path.resolve( + pluginsPath, + normalizePath(hubId), + "plugin.json" + ); + if (!this.isValidLocation(configLocation)) return; + + const currentConfig = safeJsonParse( + fs.readFileSync(configLocation, "utf8"), + null + ); + if (!currentConfig) return; + + const updatedConfig = { ...currentConfig, ...config }; + fs.writeFileSync(configLocation, JSON.stringify(updatedConfig, null, 2)); + return updatedConfig; + } + + /** + * Validates if the handler.js file exists for the given plugin. + * @param {string} hubId - The hub ID of the plugin. + * @returns {boolean} - True if the handler.js file exists, false otherwise. + */ + static validateImportedPluginHandler(hubId) { + const handlerLocation = path.resolve( + pluginsPath, + normalizePath(hubId), + "handler.js" + ); + return this.isValidLocation(handlerLocation); + } + + parseCallOptions() { + const callOpts = {}; + if (!this.config.setup_args || typeof this.config.setup_args !== "object") { + return callOpts; + } + for (const [param, definition] of Object.entries(this.config.setup_args)) { + if (definition.required && !definition?.value) { + console.log( + `'${param}' required value for '${this.name}' plugin is missing. Plugin may not function or crash agent.` + ); + continue; + } + callOpts[param] = definition.value || definition.default || null; + } + return callOpts; + } + + plugin(runtimeArgs = {}) { + const customFunctions = this.handler.runtime; + return { + runtimeArgs, + name: this.name, + config: this.config, + setup(aibitat) { + aibitat.function({ + super: aibitat, + name: this.name, + config: this.config, + runtimeArgs: this.runtimeArgs, + description: this.config.description, + logger: aibitat?.handlerProps?.log || console.log, // Allows plugin to log to the console. + introspect: aibitat?.introspect || console.log, // Allows plugin to display a "thought" the chat window UI. + examples: this.config.examples ?? [], + parameters: { + $schema: "http://json-schema.org/draft-07/schema#", + type: "object", + properties: this.config.entrypoint.params ?? {}, + additionalProperties: false, + }, + ...customFunctions, + }); + }, + }; + } +} + +module.exports = ImportedPlugin; diff --git a/server/utils/agents/index.js b/server/utils/agents/index.js index 86563d1850f7a3bc6b043088e550dfede119e974..521b9e9cad1ee7e9baaa4b7afe9d5ca577bc6ee0 100644 --- a/server/utils/agents/index.js +++ b/server/utils/agents/index.js @@ -6,6 +6,7 @@ const { const { WorkspaceChats } = require("../../models/workspaceChats"); const { safeJsonParse } = require("../http"); const { USER_AGENT, WORKSPACE_AGENT } = require("./defaults"); +const ImportedPlugin = require("./imported"); class AgentHandler { #invocationUUID; @@ -292,6 +293,27 @@ class AgentHandler { 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("@@")) { + const hubId = name.replace("@@", ""); + const valid = ImportedPlugin.validateImportedPluginHandler(hubId); + if (!valid) { + this.log( + `Imported plugin by hubId ${hubId} not found in plugin directory. Skipping inclusion to agent cluster.` + ); + continue; + } + + const plugin = ImportedPlugin.loadPluginByHubId(hubId); + const callOpts = plugin.parseCallOptions(); + this.aibitat.use(plugin.plugin(callOpts)); + this.log( + `Attached ${plugin.name} (${hubId}) imported plugin to Agent cluster` + ); + continue; + } + // Load single-stage plugin. if (!AgentPlugins.hasOwnProperty(name)) { this.log( diff --git a/server/utils/http/index.js b/server/utils/http/index.js index e812b8abd77a5805dd71eb8763d3473d3297535a..7e76f327d9a22226f88c1b5cee32aef8d53b9367 100644 --- a/server/utils/http/index.js +++ b/server/utils/http/index.js @@ -64,6 +64,8 @@ function parseAuthHeader(headerValue = null, apiKey = null) { } function safeJsonParse(jsonString, fallback = null) { + if (jsonString === null) return fallback; + try { return JSON.parse(jsonString); } catch {}