Community hub integration ()

* wip hub connection page fe + backend

* lint

* implement backend for local hub items + placeholder endpoints to fetch hub app data

* fix hebrew translations

* revamp community integration flow

* change sidebar

* Auto import if id in URL param
remove preview in card screen and instead go to import flow

* get user's items + team items from hub + ui improvements to hub settings

* lint

* fix merge conflict

* refresh hook for community items

* add fallback for user items

* Disable bundle items by default on all instances

* remove translations (will complete later)

* loading skeleton

* Make community hub endpoints admin only
show visibility on items
combine import/apply for items to they are event logged for review

* improve middleware and import flow

* community hub ui updates

* Adjust importing process

* community hub to dev

* Add webscraper preload into imported plugins

* add runtime property to plugins

* Fix button status on imported skill change
show alert on skill change
Update markdown type and theme on import of agent skill

* update documentaion paths

* remove unused import

* linting

* review loading state

---------

Co-authored-by: Timothy Carambat <rambat1010@gmail.com>
This commit is contained in:
Sean Hatfield 2024-11-26 09:59:43 -08:00 committed by GitHub
parent 8c9e9f2ec1
commit 05c530221b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
40 changed files with 2449 additions and 7 deletions

View file

@ -6,7 +6,7 @@ concurrency:
on:
push:
branches: ['2670-feat-can-the-font-size-of-the-chat-input-box-be-increased'] # put your current branch to create a build. Core team only.
branches: ['2545-feat-community-hub-integration'] # put your current branch to create a build. Core team only.
paths-ignore:
- '**.md'
- 'cloud-deployments/*'

View file

@ -69,6 +69,16 @@ const LiveDocumentSyncManage = lazy(
);
const FineTuningWalkthrough = lazy(() => import("@/pages/FineTuning"));
const CommunityHubTrending = lazy(
() => import("@/pages/GeneralSettings/CommunityHub/Trending")
);
const CommunityHubAuthentication = lazy(
() => import("@/pages/GeneralSettings/CommunityHub/Authentication")
);
const CommunityHubImportItem = lazy(
() => import("@/pages/GeneralSettings/CommunityHub/ImportItem")
);
export default function App() {
return (
<ThemeProvider>
@ -207,6 +217,21 @@ export default function App() {
path="/fine-tuning"
element={<AdminRoute Component={FineTuningWalkthrough} />}
/>
<Route
path="/settings/community-hub/trending"
element={<AdminRoute Component={CommunityHubTrending} />}
/>
<Route
path="/settings/community-hub/authentication"
element={
<AdminRoute Component={CommunityHubAuthentication} />
}
/>
<Route
path="/settings/community-hub/import-item"
element={<AdminRoute Component={CommunityHubImportItem} />}
/>
</Routes>
<ToastContainer />
</I18nextProvider>

View file

@ -11,6 +11,7 @@ import {
PencilSimpleLine,
Nut,
Toolbox,
Globe,
} from "@phosphor-icons/react";
import useUser from "@/hooks/useUser";
import { isMobile } from "react-device-detect";
@ -291,6 +292,30 @@ const SidebarOptions = ({ user = null, t }) => (
flex={true}
roles={["admin"]}
/>
<Option
btnText="Community Hub"
icon={<Globe className="h-5 w-5 flex-shrink-0" />}
childOptions={[
{
btnText: "Explore Trending",
href: paths.communityHub.trending(),
flex: true,
roles: ["admin"],
},
{
btnText: "Your Account",
href: paths.communityHub.authentication(),
flex: true,
roles: ["admin"],
},
{
btnText: "Import Item",
href: paths.communityHub.importItem(),
flex: true,
roles: ["admin"],
},
]}
/>
<Option
btnText={t("settings.customization")}
icon={<PencilSimpleLine className="h-5 w-5 flex-shrink-0" />}

View file

@ -96,7 +96,7 @@ function copyCodeSnippet(uuid) {
}
// Listens and hunts for all data-code-snippet clicks.
function setEventDelegatorForCodeSnippets() {
export function setEventDelegatorForCodeSnippets() {
document?.addEventListener("click", function (e) {
const target = e.target.closest("[data-code-snippet]");
const uuidCode = target?.dataset?.code;

View file

@ -0,0 +1,158 @@
import { API_BASE } from "@/utils/constants";
import { baseHeaders } from "@/utils/request";
const CommunityHub = {
/**
* Get an item from the community hub by its import ID.
* @param {string} importId - The import ID of the item.
* @returns {Promise<{error: string | null, item: object | null}>}
*/
getItemFromImportId: async (importId) => {
return await fetch(`${API_BASE}/community-hub/item`, {
method: "POST",
headers: baseHeaders(),
body: JSON.stringify({ importId }),
})
.then((res) => res.json())
.catch((e) => {
console.error(e);
return {
error: e.message,
item: null,
};
});
},
/**
* Apply an item to the AnythingLLM instance. Used for simple items like slash commands and system prompts.
* @param {string} importId - The import ID of the item.
* @param {object} options - Additional options for applying the item for whatever the item type requires.
* @returns {Promise<{success: boolean, error: string | null}>}
*/
applyItem: async (importId, options = {}) => {
return await fetch(`${API_BASE}/community-hub/apply`, {
method: "POST",
headers: baseHeaders(),
body: JSON.stringify({ importId, options }),
})
.then((res) => res.json())
.catch((e) => {
console.error(e);
return {
success: false,
error: e.message,
};
});
},
/**
* Import a bundle item from the community hub.
* @param {string} importId - The import ID of the item.
* @returns {Promise<{error: string | null, item: object | null}>}
*/
importBundleItem: async (importId) => {
return await fetch(`${API_BASE}/community-hub/import`, {
method: "POST",
headers: baseHeaders(),
body: JSON.stringify({ importId }),
})
.then(async (res) => {
const response = await res.json();
if (!res.ok) throw new Error(response?.error ?? res.statusText);
return response;
})
.catch((e) => {
return {
error: e.message,
item: null,
};
});
},
/**
* Update the hub settings (API key, etc.)
* @param {Object} data - The data to update.
* @returns {Promise<{success: boolean, error: string | null}>}
*/
updateSettings: async (data) => {
return await fetch(`${API_BASE}/community-hub/settings`, {
method: "POST",
headers: baseHeaders(),
body: JSON.stringify(data),
})
.then(async (res) => {
const response = await res.json();
if (!res.ok)
throw new Error(response.error || "Failed to update settings");
return { success: true, error: null };
})
.catch((e) => ({
success: false,
error: e.message,
}));
},
/**
* Get the hub settings (API key, etc.)
* @returns {Promise<{connectionKey: string | null, error: string | null}>}
*/
getSettings: async () => {
return await fetch(`${API_BASE}/community-hub/settings`, {
method: "GET",
headers: baseHeaders(),
})
.then(async (res) => {
const response = await res.json();
if (!res.ok)
throw new Error(response.error || "Failed to fetch settings");
return { connectionKey: response.connectionKey, error: null };
})
.catch((e) => ({
connectionKey: null,
error: e.message,
}));
},
/**
* Fetch the explore items from the community hub that are publicly available.
* @returns {Promise<{agentSkills: {items: [], hasMore: boolean, totalCount: number}, systemPrompts: {items: [], hasMore: boolean, totalCount: number}, slashCommands: {items: [], hasMore: boolean, totalCount: number}}>}
*/
fetchExploreItems: async () => {
return await fetch(`${API_BASE}/community-hub/explore`, {
method: "GET",
headers: baseHeaders(),
})
.then((res) => res.json())
.catch((e) => {
console.error(e);
return {
success: false,
error: e.message,
result: null,
};
});
},
/**
* Fetch the user items from the community hub.
* @returns {Promise<{success: boolean, error: string | null, createdByMe: object, teamItems: object[]}>}
*/
fetchUserItems: async () => {
return await fetch(`${API_BASE}/community-hub/items`, {
method: "GET",
headers: baseHeaders(),
})
.then((res) => res.json())
.catch((e) => {
console.error(e);
return {
success: false,
error: e.message,
createdByMe: {},
teamItems: [],
};
});
},
};
export default CommunityHub;

View file

@ -38,6 +38,20 @@ const AgentPlugins = {
return false;
});
},
deletePlugin: async function (hubId) {
return await fetch(`${API_BASE}/experimental/agent-plugins/${hubId}`, {
method: "DELETE",
headers: baseHeaders(),
})
.then((res) => {
if (!res.ok) throw new Error("Could not delete agent plugin config.");
return true;
})
.catch((e) => {
console.error(e);
return false;
});
},
};
export default AgentPlugins;

View file

@ -1,7 +1,7 @@
import System from "@/models/system";
import showToast from "@/utils/toast";
import { Plug } from "@phosphor-icons/react";
import { useEffect, useState } from "react";
import { Gear, Plug } from "@phosphor-icons/react";
import { useEffect, useState, useRef } from "react";
import { sentenceCase } from "text-case";
/**
@ -55,6 +55,11 @@ export default function ImportedSkillConfig({
prev.map((s) => (s.hubId === config.hubId ? updatedConfig : s))
);
setConfig(updatedConfig);
showToast(
`Skill ${updatedConfig.active ? "activated" : "deactivated"}.`,
"success",
{ clear: true }
);
}
async function handleSubmit(e) {
@ -91,6 +96,7 @@ export default function ImportedSkillConfig({
)
);
showToast("Skill config updated successfully.", "success");
setHasChanges(false);
}
useEffect(() => {
@ -119,6 +125,10 @@ export default function ImportedSkillConfig({
<div className="peer-disabled:opacity-50 pointer-events-none peer h-6 w-11 rounded-full bg-[#CFCFD0] after:absolute after:left-[2px] after:top-[2px] after:h-5 after:w-5 after:rounded-full after:shadow-xl after:border-none after:bg-white after:box-shadow-md after:transition-all after:content-[''] peer-checked:bg-[#32D583] peer-checked:after:translate-x-full peer-checked:after:border-white peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-transparent"></div>
<span className="ml-3 text-sm font-medium"></span>
</label>
<ManageSkillMenu
config={config}
setImportedSkills={setImportedSkills}
/>
</div>
<p className="text-white text-opacity-60 text-xs font-medium py-1.5">
{config.description} by{" "}
@ -178,3 +188,64 @@ export default function ImportedSkillConfig({
</>
);
}
function ManageSkillMenu({ config, setImportedSkills }) {
const [open, setOpen] = useState(false);
const menuRef = useRef(null);
async function deleteSkill() {
if (
!window.confirm(
"Are you sure you want to delete this skill? This action cannot be undone."
)
)
return;
const success = await System.experimentalFeatures.agentPlugins.deletePlugin(
config.hubId
);
if (success) {
setImportedSkills((prev) => prev.filter((s) => s.hubId !== config.hubId));
showToast("Skill deleted successfully.", "success");
setOpen(false);
} else {
showToast("Failed to delete skill.", "error");
}
}
useEffect(() => {
const handleClickOutside = (event) => {
if (menuRef.current && !menuRef.current.contains(event.target)) {
setOpen(false);
}
};
document.addEventListener("mousedown", handleClickOutside);
return () => {
document.removeEventListener("mousedown", handleClickOutside);
};
}, []);
if (!config.hubId) return null;
return (
<div className="relative" ref={menuRef}>
<button
type="button"
onClick={() => setOpen(!open)}
className={`border-none transition duration-200 hover:rotate-90 outline-none ring-none ${open ? "rotate-90" : ""}`}
>
<Gear size={24} weight="bold" />
</button>
{open && (
<div className="absolute w-[100px] -top-1 left-7 mt-1 border-[1.5px] border-white/40 rounded-lg bg-theme-action-menu-bg flex flex-col shadow-[0_4px_14px_rgba(0,0,0,0.25)] text-white z-99 md:z-10">
<button
type="button"
onClick={deleteSkill}
className="border-none flex items-center rounded-lg gap-x-2 hover:bg-theme-action-menu-item-hover py-1.5 px-2 transition-colors duration-200 w-full text-left"
>
<span className="text-sm">Delete Skill</span>
</button>
</div>
)}
</div>
);
}

View file

@ -0,0 +1,89 @@
import paths from "@/utils/paths";
import HubItemCard from "../../Trending/HubItems/HubItemCard";
import { useUserItems } from "../useUserItems";
import { HubItemCardSkeleton } from "../../Trending/HubItems";
import { readableType } from "../../utils";
export default function UserItems({ connectionKey }) {
const { loading, userItems } = useUserItems({ connectionKey });
const { createdByMe = {}, teamItems = [] } = userItems || {};
if (loading) return <HubItemCardSkeleton />;
return (
<div className="flex flex-col gap-y-8">
{/* Created By Me Section */}
<div className="w-full flex flex-col gap-y-1 pb-6 border-white border-b-2 border-opacity-10">
<div className="flex items-center justify-between">
<p className="text-lg leading-6 font-bold text-white">
Created by me
</p>
<a
href={paths.communityHub.noPrivateItems()}
target="_blank"
rel="noreferrer"
className="text-primary-button hover:text-primary-button/80 text-sm"
>
Why can't I see my private items?
</a>
</div>
<p className="text-xs leading-[18px] font-base text-white text-opacity-60">
Items you have created and shared publicly on the AnythingLLM
Community Hub.
</p>
<div className="flex flex-col gap-4 mt-4">
{Object.keys(createdByMe).map((type) => {
if (!createdByMe[type]?.items?.length) return null;
return (
<div key={type} className="rounded-lg w-full">
<h3 className="text-white capitalize font-medium mb-3">
{readableType(type)}
</h3>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-2">
{createdByMe[type].items.map((item) => (
<HubItemCard key={item.id} type={type} item={item} />
))}
</div>
</div>
);
})}
</div>
</div>
{/* Team Items Section */}
<div className="w-full flex flex-col gap-y-1 pb-6 border-white border-b-2 border-opacity-10">
<div className="items-center">
<p className="text-lg leading-6 font-bold text-white">
Items by team
</p>
</div>
<p className="text-xs leading-[18px] font-base text-white text-opacity-60">
Public and private items shared with teams you belong to.
</p>
<div className="flex flex-col gap-4 mt-4">
{teamItems.map((team) => (
<div key={team.teamId} className="flex flex-col gap-y-4">
<h3 className="text-white text-sm font-medium">
{team.teamName}
</h3>
{Object.keys(team.items).map((type) => {
if (team.items[type].items.length === 0) return null;
return (
<div key={type} className="rounded-lg w-full">
<h3 className="text-white capitalize font-medium mb-3">
{readableType(type)}
</h3>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-2">
{team.items[type].items.map((item) => (
<HubItemCard key={item.id} type={type} item={item} />
))}
</div>
</div>
);
})}
</div>
))}
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,174 @@
import Sidebar from "@/components/SettingsSidebar";
import { isMobile } from "react-device-detect";
import { useEffect, useState } from "react";
import CommunityHub from "@/models/communityHub";
import ContextualSaveBar from "@/components/ContextualSaveBar";
import showToast from "@/utils/toast";
import { FullScreenLoader } from "@/components/Preloader";
import paths from "@/utils/paths";
import { Info } from "@phosphor-icons/react";
import UserItems from "./UserItems";
function useCommunityHubAuthentication() {
const [originalConnectionKey, setOriginalConnectionKey] = useState("");
const [hasChanges, setHasChanges] = useState(false);
const [connectionKey, setConnectionKey] = useState("");
const [loading, setLoading] = useState(true);
async function resetChanges() {
setConnectionKey(originalConnectionKey);
setHasChanges(false);
}
async function onConnectionKeyChange(e) {
const newConnectionKey = e.target.value;
setConnectionKey(newConnectionKey);
setHasChanges(true);
}
async function updateConnectionKey() {
if (connectionKey === originalConnectionKey) return;
setLoading(true);
try {
const response = await CommunityHub.updateSettings({
hub_api_key: connectionKey,
});
if (!response.success)
return showToast("Failed to save API key", "error");
setHasChanges(false);
showToast("API key saved successfully", "success");
setOriginalConnectionKey(connectionKey);
} catch (error) {
console.error(error);
showToast("Failed to save API key", "error");
} finally {
setLoading(false);
}
}
useEffect(() => {
const fetchData = async () => {
setLoading(true);
try {
const { connectionKey } = await CommunityHub.getSettings();
setOriginalConnectionKey(connectionKey || "");
setConnectionKey(connectionKey || "");
} catch (error) {
console.error("Error fetching data:", error);
} finally {
setLoading(false);
}
};
fetchData();
}, []);
return {
connectionKey,
originalConnectionKey,
loading,
onConnectionKeyChange,
updateConnectionKey,
hasChanges,
resetChanges,
};
}
export default function CommunityHubAuthentication() {
const {
connectionKey,
originalConnectionKey,
loading,
onConnectionKeyChange,
updateConnectionKey,
hasChanges,
resetChanges,
} = useCommunityHubAuthentication();
if (loading) return <FullScreenLoader />;
return (
<div className="w-screen h-screen overflow-hidden bg-theme-bg-container flex">
<Sidebar />
<ContextualSaveBar
showing={hasChanges}
onSave={updateConnectionKey}
onCancel={resetChanges}
/>
<div
style={{ height: isMobile ? "100%" : "calc(100% - 32px)" }}
className="relative md:ml-[2px] md:mr-[16px] md:my-[16px] md:rounded-[16px] bg-theme-bg-secondary w-full h-full overflow-y-scroll p-4 md:p-0"
>
<div className="flex flex-col w-full px-1 md:pl-6 md:pr-[86px] md:py-6 py-16">
<div className="w-full flex flex-col gap-y-1 pb-6 border-white light:border-theme-sidebar-border border-b-2 border-opacity-10">
<div className="items-center">
<p className="text-lg leading-6 font-bold text-theme-text-primary">
Your AnythingLLM Community Hub Account
</p>
</div>
<p className="text-xs leading-[18px] font-base text-theme-text-secondary">
Connecting your AnythingLLM Community Hub account allows you to
access your <b>private</b> AnythingLLM Community Hub items as well
as upload your own items to the AnythingLLM Community Hub.
</p>
</div>
{!connectionKey && (
<div className="border border-theme-border my-2 flex flex-col md:flex-row md:items-center gap-x-2 text-theme-text-primary mb-4 bg-theme-settings-input-bg w-1/2 rounded-lg px-4 py-2">
<div className="flex flex-col gap-y-2">
<div className="gap-x-2 flex items-center">
<Info size={25} />
<h1 className="text-lg font-semibold">
Why connect my AnythingLLM Community Hub account?
</h1>
</div>
<p className="text-sm text-theme-text-secondary">
Connecting your AnythingLLM Community Hub account allows you
to pull in your <b>private</b> items from the AnythingLLM
Community Hub as well as upload your own items to the
AnythingLLM Community Hub.
<br />
<br />
<i>
You do not need to connect your AnythingLLM Community Hub
account to pull in public items from the AnythingLLM
Community Hub.
</i>
</p>
</div>
</div>
)}
{/* API Key Section */}
<div className="mt-6 mb-12">
<div className="flex flex-col w-full max-w-[400px]">
<label className="text-theme-text-primary text-sm font-semibold block mb-2">
AnythingLLM Hub API Key
</label>
<input
type="password"
value={connectionKey || ""}
onChange={onConnectionKeyChange}
className="bg-theme-settings-input-bg text-theme-text-primary placeholder:text-theme-settings-input-placeholder text-sm rounded-lg focus:outline-primary-button active:outline-primary-button outline-none block w-full p-2.5"
placeholder="Enter your AnythingLLM Hub API key"
/>
<p className="text-theme-text-secondary text-xs mt-2">
You can get your API key from your{" "}
<a
href={paths.communityHub.profile()}
className="underline text-primary-button"
>
AnythingLLM Community Hub profile page
</a>
.
</p>
</div>
</div>
{!!originalConnectionKey && (
<div className="mt-6">
<UserItems connectionKey={originalConnectionKey} />
</div>
)}
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,39 @@
import { useState, useEffect } from "react";
import CommunityHub from "@/models/communityHub";
const DEFAULT_USER_ITEMS = {
createdByMe: {
agentSkills: { items: [] },
systemPrompts: { items: [] },
slashCommands: { items: [] },
},
teamItems: [],
};
export function useUserItems({ connectionKey }) {
const [loading, setLoading] = useState(true);
const [userItems, setUserItems] = useState(DEFAULT_USER_ITEMS);
useEffect(() => {
const fetchData = async () => {
console.log("fetching user items", connectionKey);
if (!connectionKey) return;
setLoading(true);
try {
const { success, createdByMe, teamItems } =
await CommunityHub.fetchUserItems();
if (success) {
setUserItems({ createdByMe, teamItems });
}
} catch (error) {
console.error("Error fetching user items:", error);
} finally {
setLoading(false);
}
};
fetchData();
}, [connectionKey]);
return { loading, userItems };
}

View file

@ -0,0 +1,36 @@
import CommunityHubImportItemSteps from "..";
import CTAButton from "@/components/lib/CTAButton";
export default function Completed({ settings, setSettings, setStep }) {
return (
<div className="flex-[2] flex flex-col gap-y-[18px] mt-10">
<div className="bg-theme-bg-primary light:bg-slate-100 shadow-lg text-theme-text-primary rounded-xl flex-1 p-6">
<div className="w-full flex flex-col gap-y-2 max-w-[700px]">
<h2 className="text-base font-semibold">
Community Hub Item Imported
</h2>
<div className="flex flex-col gap-y-[25px] text-theme-text-secondary text-sm">
<p>
The "{settings.item.name}" {settings.item.itemType} has been
imported successfully! It is now available in your AnythingLLM
instance.
</p>
<p>
Any changes you make to this {settings.item.itemType} will not be
reflected in the community hub. You can now modify as needed.
</p>
</div>
<CTAButton
className="text-dark-text w-full mt-[18px] h-[34px] hover:bg-accent"
onClick={() => {
setSettings({ item: null, itemId: null });
setStep(CommunityHubImportItemSteps.itemId.key);
}}
>
Import another item
</CTAButton>
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,76 @@
import CommunityHubImportItemSteps from "..";
import CTAButton from "@/components/lib/CTAButton";
import paths from "@/utils/paths";
import showToast from "@/utils/toast";
import { useState } from "react";
export default function Introduction({ settings, setSettings, setStep }) {
const [itemId, setItemId] = useState(settings.itemId);
const handleContinue = () => {
if (!itemId) return showToast("Please enter an item ID", "error");
setSettings((prev) => ({ ...prev, itemId }));
setStep(CommunityHubImportItemSteps.itemId.next());
};
return (
<div className="flex-[2] flex flex-col gap-y-[18px] mt-10">
<div className="bg-theme-bg-primary light:bg-slate-100 shadow-lg text-theme-text-primary rounded-xl flex-1 p-6">
<div className="w-full flex flex-col gap-y-2 max-w-[700px]">
<h2 className="text-base font-semibold">
Import an item from the community hub
</h2>
<div className="flex flex-col gap-y-[25px] text-theme-text-secondary">
<p>
The community hub is a place where you can find, share, and import
agent-skills, system prompts, slash commands, and more!
</p>
<p>
These items are created by the AnythingLLM team and community, and
are a great way to get started with AnythingLLM as well as extend
AnythingLLM in a way that is customized to your needs.
</p>
<p>
There are both <b>private</b> and <b>public</b> items in the
community hub. Private items are only visible to you, while public
items are visible to everyone.
</p>
<p className="p-4 bg-yellow-800/30 light:bg-yellow-100 rounded-lg border border-yellow-500 text-yellow-500">
If you are pulling in a private item, make sure it is{" "}
<b>shared with a team</b> you belong to, and you have added a{" "}
<a
href={paths.communityHub.authentication()}
className="underline text-yellow-100 light:text-yellow-500 font-semibold"
>
Connection Key.
</a>
</p>
</div>
<div className="flex flex-col gap-y-2 mt-4">
<div className="w-full flex flex-col gap-y-4">
<div className="flex flex-col w-full">
<label className="text-sm font-semibold block mb-3">
Community Hub Item Import ID
</label>
<input
type="text"
value={itemId}
onChange={(e) => setItemId(e.target.value)}
placeholder="allm-community-id:agent-skill:1234567890"
className="bg-zinc-900 light:bg-white text-theme-text-primary placeholder:text-theme-settings-input-placeholder text-sm rounded-lg focus:outline-primary-button active:outline-primary-button outline-none block w-full p-2.5"
/>
</div>
</div>
</div>
<CTAButton
className="text-dark-text w-full mt-[18px] h-[34px] hover:bg-accent"
onClick={handleContinue}
>
Continue with import &rarr;
</CTAButton>
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,186 @@
import CTAButton from "@/components/lib/CTAButton";
import CommunityHubImportItemSteps from "../..";
import showToast from "@/utils/toast";
import paths from "@/utils/paths";
import {
CaretLeft,
CaretRight,
CircleNotch,
Warning,
} from "@phosphor-icons/react";
import { useEffect, useState } from "react";
import renderMarkdown from "@/utils/chat/markdown";
import DOMPurify from "dompurify";
import CommunityHub from "@/models/communityHub";
import { setEventDelegatorForCodeSnippets } from "@/components/WorkspaceChat";
export default function AgentSkill({ item, settings, setStep }) {
const [loading, setLoading] = useState(false);
async function importAgentSkill() {
try {
setLoading(true);
const { error } = await CommunityHub.importBundleItem(settings.itemId);
if (error) throw new Error(error);
showToast(`Agent skill imported successfully!`, "success");
setStep(CommunityHubImportItemSteps.completed.key);
} catch (e) {
console.error(e);
showToast(`Failed to import agent skill. ${e.message}`, "error");
} finally {
setLoading(false);
}
}
useEffect(() => {
setEventDelegatorForCodeSnippets();
}, []);
return (
<div className="flex flex-col mt-4 gap-y-4">
<div className="border border-white/10 light:border-orange-500/20 my-2 flex flex-col md:flex-row md:items-center gap-x-2 text-theme-text-primary mb-4 bg-orange-800/30 light:bg-orange-500/10 rounded-lg px-4 py-2">
<div className="flex flex-col gap-y-2">
<div className="gap-x-2 flex items-center">
<Warning size={25} />
<h1 className="text-lg font-semibold">
{" "}
Only import agent skills you trust{" "}
</h1>
</div>
<p className="text-sm">
Agent skills can execute code on your AnythingLLM instance, so only
import agent skills from sources you trust. You should also review
the code before importing. If you are unsure about what a skill does
- don't import it!
</p>
</div>
</div>
<div className="flex flex-col gap-y-1">
<h2 className="text-base text-theme-text-primary font-semibold">
Review Agent Skill "{item.name}"
</h2>
{item.creatorUsername && (
<p className="text-white/60 light:text-theme-text-secondary text-xs font-mono">
Created by{" "}
<a
href={paths.communityHub.profile(item.creatorUsername)}
target="_blank"
className="hover:text-blue-500 hover:underline"
rel="noreferrer"
>
@{item.creatorUsername}
</a>
</p>
)}
<div className="flex gap-x-1">
{item.verified ? (
<p className="text-green-500 text-xs font-mono">Verified code</p>
) : (
<p className="text-red-500 text-xs font-mono">
This skill is not verified.
</p>
)}
<a
href="https://docs.anythingllm.com/community-hub/faq#verification"
target="_blank"
className="text-xs font-mono text-blue-500 hover:underline"
rel="noreferrer"
>
Learn more &rarr;
</a>
</div>
</div>
<div className="flex flex-col gap-y-[25px] text-white/80 light:text-theme-text-secondary text-sm">
<p>
Agent skills unlock new capabilities for your AnythingLLM workspace
via{" "}
<code className="font-mono bg-zinc-900 light:bg-slate-200 px-1 py-0.5 rounded-md text-sm">
@agent
</code>{" "}
skills that can do specific tasks when invoked.
</p>
</div>
<FileReview item={item} />
<CTAButton
disabled={loading}
className="text-dark-text w-full mt-[18px] h-[34px] hover:bg-accent"
onClick={importAgentSkill}
>
{loading ? <CircleNotch size={16} className="animate-spin" /> : null}
{loading ? "Importing..." : "Import agent skill"}
</CTAButton>
</div>
);
}
function FileReview({ item }) {
const files = item.manifest.files || [];
const [index, setIndex] = useState(0);
const [file, setFile] = useState(files[index]);
function handlePrevious() {
if (index > 0) setIndex(index - 1);
}
function handleNext() {
if (index < files.length - 1) setIndex(index + 1);
}
function fileMarkup(file) {
const extension = file.name.split(".").pop();
switch (extension) {
case "js":
return "javascript";
case "json":
return "json";
case "md":
return "markdown";
default:
return "text";
}
}
useEffect(() => {
if (files.length > 0) setFile(files?.[index] || files[0]);
}, [index]);
if (!file) return null;
return (
<div className="flex flex-col gap-y-2">
<div className="flex flex-col gap-y-2">
<div className="flex justify-between items-center">
<button
type="button"
className={`bg-black/70 light:bg-slate-200 rounded-md p-1 text-white/60 light:text-theme-text-secondary text-xs font-mono ${
index === 0 ? "opacity-50 cursor-not-allowed" : ""
}`}
onClick={handlePrevious}
>
<CaretLeft size={16} />
</button>
<p className="text-white/60 light:text-theme-text-secondary text-xs font-mono">
{file.name} ({index + 1} of {files.length} files)
</p>
<button
type="button"
className={`bg-black/70 light:bg-slate-200 rounded-md p-1 text-white/60 light:text-theme-text-secondary text-xs font-mono ${
index === files.length - 1 ? "opacity-50 cursor-not-allowed" : ""
}`}
onClick={handleNext}
>
<CaretRight size={16} />
</button>
</div>
<span
className="whitespace-pre-line flex flex-col gap-y-1 text-sm leading-[20px] max-h-[500px] overflow-y-auto hljs"
dangerouslySetInnerHTML={{
__html: DOMPurify.sanitize(
renderMarkdown(
`\`\`\`${fileMarkup(file)}\n${file.content}\n\`\`\``
)
),
}}
/>
</div>
</div>
);
}

View file

@ -0,0 +1,81 @@
import CTAButton from "@/components/lib/CTAButton";
import CommunityHubImportItemSteps from "../..";
import showToast from "@/utils/toast";
import paths from "@/utils/paths";
import CommunityHub from "@/models/communityHub";
export default function SlashCommand({ item, setStep }) {
async function handleSubmit() {
try {
const { error } = await CommunityHub.applyItem(item.importId);
if (error) throw new Error(error);
showToast(
`Slash command ${item.command} imported successfully!`,
"success"
);
setStep(CommunityHubImportItemSteps.completed.key);
} catch (e) {
console.error(e);
showToast(`Failed to import slash command. ${e.message}`, "error");
} finally {
setLoading(false);
}
}
return (
<div className="flex flex-col mt-4 gap-y-4">
<div className="flex flex-col gap-y-1">
<h2 className="text-base text-theme-text-primary font-semibold">
Review Slash Command "{item.name}"
</h2>
{item.creatorUsername && (
<p className="text-white/60 text-xs font-mono">
Created by{" "}
<a
href={paths.communityHub.profile(item.creatorUsername)}
target="_blank"
className="hover:text-blue-500 hover:underline"
rel="noreferrer"
>
@{item.creatorUsername}
</a>
</p>
)}
</div>
<div className="flex flex-col gap-y-[25px] text-white/80 light:text-theme-text-secondary text-sm">
<p>
Slash commands are used to prefill information into a prompt while
chatting with a AnythingLLM workspace.
<br />
<br />
The slash command will be available during chatting by simply invoking
it with{" "}
<code className="font-mono bg-zinc-900 light:bg-slate-200 px-1 py-0.5 rounded-md text-sm">
{item.command}
</code>{" "}
like you would any other command.
</p>
<div className="flex flex-col gap-y-2 mt-2">
<div className="w-full text-theme-text-primary text-md gap-x-2 flex items-center">
<p className="text-white/60 light:text-theme-text-secondary w-fit font-mono bg-zinc-900 light:bg-slate-200 px-2 py-1 rounded-md text-sm whitespace-pre-line">
{item.command}
</p>
</div>
<div className="w-full text-theme-text-primary text-md flex flex-col gap-y-2">
<p className="text-white/60 light:text-theme-text-secondary font-mono bg-zinc-900 light:bg-slate-200 p-4 rounded-md text-sm whitespace-pre-line max-h-[calc(200px)] overflow-y-auto">
{item.prompt}
</p>
</div>
</div>
</div>
<CTAButton
className="text-dark-text w-full mt-[18px] h-[34px] hover:bg-accent"
onClick={handleSubmit}
>
Import slash command
</CTAButton>
</div>
);
}

View file

@ -0,0 +1,106 @@
import CTAButton from "@/components/lib/CTAButton";
import CommunityHubImportItemSteps from "../..";
import { useEffect, useState } from "react";
import Workspace from "@/models/workspace";
import showToast from "@/utils/toast";
import paths from "@/utils/paths";
import CommunityHub from "@/models/communityHub";
export default function SystemPrompt({ item, setStep }) {
const [destinationWorkspaceSlug, setDestinationWorkspaceSlug] =
useState(null);
const [workspaces, setWorkspaces] = useState([]);
useEffect(() => {
async function getWorkspaces() {
const workspaces = await Workspace.all();
setWorkspaces(workspaces);
setDestinationWorkspaceSlug(workspaces[0].slug);
}
getWorkspaces();
}, []);
async function handleSubmit() {
showToast("Applying system prompt to workspace...", "info");
const { error } = await CommunityHub.applyItem(item.importId, {
workspaceSlug: destinationWorkspaceSlug,
});
if (error) {
return showToast(`Failed to apply system prompt. ${error}`, "error", {
clear: true,
});
}
showToast("System prompt applied to workspace.", "success", {
clear: true,
});
setStep(CommunityHubImportItemSteps.completed.key);
}
return (
<div className="flex flex-col mt-4 gap-y-4">
<div className="flex flex-col gap-y-1">
<h2 className="text-base text-theme-text-primary font-semibold">
Review System Prompt "{item.name}"
</h2>
{item.creatorUsername && (
<p className="text-white/60 light:text-theme-text-secondary text-xs font-mono">
Created by{" "}
<a
href={paths.communityHub.profile(item.creatorUsername)}
target="_blank"
className="hover:text-blue-500 hover:underline"
rel="noreferrer"
>
@{item.creatorUsername}
</a>
</p>
)}
</div>
<div className="flex flex-col gap-y-[25px] text-white/80 light:text-theme-text-secondary text-sm">
<p>
System prompts are used to guide the behavior of the AI agents and can
be applied to any existing workspace.
</p>
<div className="flex flex-col gap-y-2">
<p className="text-white/60 light:text-theme-text-secondary font-semibold">
Provided system prompt:
</p>
<div className="w-full text-theme-text-primary text-md flex flex-col max-h-[calc(300px)] overflow-y-auto">
<p className="text-white/60 light:text-theme-text-secondary font-mono bg-zinc-900 light:bg-slate-200 px-2 py-1 rounded-md text-sm whitespace-pre-line">
{item.prompt}
</p>
</div>
</div>
<div className="flex flex-col w-60">
<label className="text-theme-text-primary text-sm font-semibold block mb-3">
Apply to Workspace
</label>
<select
name="destinationWorkspaceSlug"
required={true}
onChange={(e) => setDestinationWorkspaceSlug(e.target.value)}
className="bg-zinc-900 light:bg-white border-gray-500 text-theme-text-primary text-sm rounded-lg block w-full p-2.5"
>
<optgroup label="Available workspaces">
{workspaces.map((workspace) => (
<option key={workspace.id} value={workspace.slug}>
{workspace.name}
</option>
))}
</optgroup>
</select>
</div>
</div>
{destinationWorkspaceSlug && (
<CTAButton
className="text-dark-text w-full mt-[18px] h-[34px] hover:bg-accent"
onClick={handleSubmit}
>
Apply system prompt to workspace
</CTAButton>
)}
</div>
);
}

View file

@ -0,0 +1,39 @@
import CTAButton from "@/components/lib/CTAButton";
import CommunityHubImportItemSteps from "../..";
import { Warning } from "@phosphor-icons/react";
export default function UnknownItem({ item, setSettings, setStep }) {
return (
<div className="flex flex-col mt-4 gap-y-4">
<div className="w-full flex items-center gap-x-2">
<Warning size={24} className="text-red-500" />
<h2 className="text-base text-red-500 font-semibold">
Unsupported item
</h2>
</div>
<div className="flex flex-col gap-y-[25px] text-white/80 text-sm">
<p>
We found an item in the community hub, but we don't know what it is or
it is not yet supported for import into AnythingLLM.
</p>
<p>
The item ID is: <b>{item.id}</b>
<br />
The item type is: <b>{item.itemType}</b>
</p>
<p>
Please contact support via email if you need help importing this item.
</p>
</div>
<CTAButton
className="text-dark-text w-full mt-[18px] h-[34px] hover:bg-accent"
onClick={() => {
setSettings({ itemId: null, item: null });
setStep(CommunityHubImportItemSteps.itemId.key);
}}
>
Try another item
</CTAButton>
</div>
);
}

View file

@ -0,0 +1,13 @@
import SystemPrompt from "./SystemPrompt";
import SlashCommand from "./SlashCommand";
import UnknownItem from "./Unknown";
import AgentSkill from "./AgentSkill";
const HubItemComponent = {
"agent-skill": AgentSkill,
"system-prompt": SystemPrompt,
"slash-command": SlashCommand,
unknown: UnknownItem,
};
export default HubItemComponent;

View file

@ -0,0 +1,85 @@
import CommunityHub from "@/models/communityHub";
import CommunityHubImportItemSteps from "..";
import CTAButton from "@/components/lib/CTAButton";
import { useEffect, useState } from "react";
import HubItemComponent from "./HubItem";
import PreLoader from "@/components/Preloader";
function useGetCommunityHubItem({ importId, updateSettings }) {
const [item, setItem] = useState(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
useEffect(() => {
async function fetchItem() {
if (!importId) return;
setLoading(true);
await new Promise((resolve) => setTimeout(resolve, 2000));
const { error, item } = await CommunityHub.getItemFromImportId(importId);
if (error) setError(error);
setItem(item);
updateSettings((prev) => ({ ...prev, item }));
setLoading(false);
}
fetchItem();
}, [importId]);
return { item, loading, error };
}
export default function PullAndReview({ settings, setSettings, setStep }) {
const { item, loading, error } = useGetCommunityHubItem({
importId: settings.itemId,
updateSettings: setSettings,
});
const ItemComponent =
HubItemComponent[item?.itemType] || HubItemComponent["unknown"];
return (
<div className="flex-[2] flex flex-col gap-y-[18px] mt-10">
<div className="bg-theme-bg-primary light:bg-slate-100 shadow-lg text-theme-text-primary rounded-xl flex-1 p-6">
<div className="w-full flex flex-col gap-y-2 max-w-[700px]">
<h2 className="text-base font-semibold">Review item</h2>
{loading && (
<div className="flex h-[200px] min-w-[746px] bg-theme-bg-container light:bg-slate-200 rounded-lg animate-pulse">
<div className="w-full h-full flex items-center justify-center">
<p className="text-sm text-theme-text-secondary">
Pulling item details from community hub...
</p>
</div>
</div>
)}
{!loading && error && (
<>
<div className="flex flex-col gap-y-2 mt-8">
<p className="text-red-500">
An error occurred while fetching the item. Please try again
later.
</p>
<p className="text-red-500/80 text-sm font-mono">{error}</p>
</div>
<CTAButton
className="text-dark-text w-full mt-[18px] h-[34px] hover:bg-accent"
onClick={() => {
setSettings({ itemId: null, item: null });
setStep(CommunityHubImportItemSteps.itemId.key);
}}
>
Try another item
</CTAButton>
</>
)}
{!loading && !error && item && (
<ItemComponent
item={item}
settings={settings}
setSettings={setSettings}
setStep={setStep}
/>
)}
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,77 @@
import { isMobile } from "react-device-detect";
import { useEffect, useState } from "react";
import Sidebar from "@/components/SettingsSidebar";
import Introduction from "./Introduction";
import PullAndReview from "./PullAndReview";
import Completed from "./Completed";
import useQuery from "@/hooks/useQuery";
const CommunityHubImportItemSteps = {
itemId: {
key: "itemId",
name: "1. Paste in Item ID",
next: () => "validation",
component: ({ settings, setSettings, setStep }) => (
<Introduction
settings={settings}
setSettings={setSettings}
setStep={setStep}
/>
),
},
validation: {
key: "validation",
name: "2. Review item",
next: () => "completed",
component: ({ settings, setSettings, setStep }) => (
<PullAndReview
settings={settings}
setSettings={setSettings}
setStep={setStep}
/>
),
},
completed: {
key: "completed",
name: "3. Completed",
component: ({ settings, setSettings, setStep }) => (
<Completed
settings={settings}
setSettings={setSettings}
setStep={setStep}
/>
),
},
};
export function CommunityHubImportItemLayout({ setStep, children }) {
const query = useQuery();
const [settings, setSettings] = useState({
itemId: null,
item: null,
});
useEffect(() => {
function autoForward() {
if (query.get("id")) {
setSettings({ itemId: query.get("id") });
setStep(CommunityHubImportItemSteps.itemId.next());
}
}
autoForward();
}, []);
return (
<div className="w-screen h-screen overflow-hidden bg-theme-bg-container flex">
<Sidebar />
<div
style={{ height: isMobile ? "100%" : "calc(100% - 32px)" }}
className="relative md:ml-[2px] md:mr-[16px] md:my-[16px] md:rounded-[16px] bg-theme-bg-secondary w-full h-full flex p-4 md:p-0"
>
{children(settings, setSettings, setStep)}
</div>
</div>
);
}
export default CommunityHubImportItemSteps;

View file

@ -0,0 +1,106 @@
import React, { useState } from "react";
import { isMobile } from "react-device-detect";
import CommunityHubImportItemSteps, {
CommunityHubImportItemLayout,
} from "./Steps";
function SideBarSelection({ setStep, currentStep }) {
const currentIndex = Object.keys(CommunityHubImportItemSteps).indexOf(
currentStep
);
return (
<div
className={`bg-white/5 light:bg-white text-theme-text-primary light:border rounded-xl py-1 px-4 shadow-lg ${
isMobile ? "w-full" : "min-w-[360px] w-fit"
}`}
>
{Object.entries(CommunityHubImportItemSteps).map(
([stepKey, props], index) => {
const isSelected = currentStep === stepKey;
const isLast =
index === Object.keys(CommunityHubImportItemSteps).length - 1;
const isDone =
currentIndex ===
Object.keys(CommunityHubImportItemSteps).length - 1 ||
index < currentIndex;
return (
<div
key={stepKey}
className={[
"py-3 flex items-center justify-between transition-all duration-300",
isSelected ? "rounded-t-xl" : "",
isLast
? ""
: "border-b border-white/10 light:border-[#026AA2]/10",
].join(" ")}
>
{isDone || isSelected ? (
<button
onClick={() => setStep(stepKey)}
className="border-none hover:underline text-sm font-medium text-theme-text-primary"
>
{props.name}
</button>
) : (
<div className="text-sm text-theme-text-secondary font-medium">
{props.name}
</div>
)}
<div className="flex items-center gap-x-2">
{isDone ? (
<div className="w-[14px] h-[14px] rounded-full border border-[#32D583] flex items-center justify-center">
<div className="w-[5.6px] h-[5.6px] rounded-full bg-[#6CE9A6]"></div>
</div>
) : (
<div
className={`w-[14px] h-[14px] rounded-full border border-theme-text-primary ${
isSelected ? "animate-pulse" : "opacity-50"
}`}
/>
)}
</div>
</div>
);
}
)}
</div>
);
}
export default function CommunityHubImportItemFlow() {
const [step, setStep] = useState("itemId");
const StepPage = CommunityHubImportItemSteps.hasOwnProperty(step)
? CommunityHubImportItemSteps[step]
: CommunityHubImportItemSteps.itemId;
return (
<CommunityHubImportItemLayout setStep={setStep}>
{(settings, setSettings, setStep) => (
<div className="flex flex-col w-full px-1 md:pl-6 md:pr-[86px] md:py-6 py-16">
<div className="w-full flex flex-col gap-y-1 pb-6 border-white light:border-theme-sidebar-border border-b-2 border-opacity-10">
<div className="items-center">
<p className="text-lg leading-6 font-bold text-theme-text-primary">
Import a Community Item
</p>
</div>
<p className="text-xs leading-[18px] font-base text-theme-text-secondary">
Import items from the AnythingLLM Community Hub to enhance your
instance with community-created prompts, skills, and commands.
</p>
</div>
<div className="flex-1 flex h-full">
<div className="flex flex-col gap-y-[18px] mt-10 w-[360px] flex-shrink-0">
<SideBarSelection setStep={setStep} currentStep={step} />
</div>
<div className="overflow-y-auto pb-[200px] h-screen no-scroll">
<div className="ml-8">
{StepPage.component({ settings, setSettings, setStep })}
</div>
</div>
</div>
</div>
)}
</CommunityHubImportItemLayout>
);
}

View file

@ -0,0 +1,45 @@
import { Link } from "react-router-dom";
import paths from "@/utils/paths";
import pluralize from "pluralize";
import { VisibilityIcon } from "./generic";
export default function AgentSkillHubCard({ item }) {
return (
<>
<Link
key={item.id}
to={paths.communityHub.importItem(item.importId)}
className="bg-black/70 light:bg-slate-100 rounded-lg p-3 hover:bg-black/60 light:hover:bg-slate-200 transition-all duration-200 cursor-pointer group border border-transparent hover:border-slate-400"
>
<div className="flex gap-x-2 items-center">
<p className="text-white text-sm font-medium">{item.name}</p>
<VisibilityIcon visibility={item.visibility} />
</div>
<div className="flex flex-col gap-2">
<p className="text-white/60 text-xs mt-1">{item.description}</p>
<p className="font-mono text-xs mt-1 text-white/60">
{item.verified ? (
<span className="text-green-500">Verified</span>
) : (
<span className="text-red-500">Unverified</span>
)}{" "}
Skill
</p>
<p className="font-mono text-xs mt-1 text-white/60">
{item.manifest.files?.length || 0}{" "}
{pluralize("file", item.manifest.files?.length || 0)} found
</p>
</div>
<div className="flex justify-end mt-2">
<Link
to={paths.communityHub.importItem(item.importId)}
className="text-primary-button hover:text-primary-button/80 text-sm font-medium px-3 py-1.5 rounded-md bg-black/30 light:bg-slate-200 group-hover:bg-black/50 light:group-hover:bg-slate-300 transition-all"
>
Import
</Link>
</div>
</Link>
</>
);
}

View file

@ -0,0 +1,45 @@
import paths from "@/utils/paths";
import { Eye, LockSimple } from "@phosphor-icons/react";
import { Link } from "react-router-dom";
import { Tooltip } from "react-tooltip";
export default function GenericHubCard({ item }) {
return (
<div
key={item.id}
className="bg-zinc-800 light:bg-slate-100 rounded-lg p-3 hover:bg-zinc-700 light:hover:bg-slate-200 transition-all duration-200"
>
<p className="text-white text-sm font-medium">{item.name}</p>
<p className="text-white/60 text-xs mt-1">{item.description}</p>
<div className="flex justify-end mt-2">
<Link
className="text-primary-button hover:text-primary-button/80 text-xs"
to={paths.communityHub.importItem(item.importId)}
>
Import
</Link>
</div>
</div>
);
}
export function VisibilityIcon({ visibility = "public" }) {
const Icon = visibility === "private" ? LockSimple : Eye;
return (
<>
<div
data-tooltip-id="visibility-icon"
data-tooltip-content={`This item is ${visibility === "private" ? "private" : "public"}`}
>
<Icon className="w-4 h-4 text-white/60" />
</div>
<Tooltip
id="visibility-icon"
place="top"
delayShow={300}
className="allm-tooltip !allm-text-xs"
/>
</>
);
}

View file

@ -0,0 +1,17 @@
import GenericHubCard from "./generic";
import SystemPromptHubCard from "./systemPrompt";
import SlashCommandHubCard from "./slashCommand";
import AgentSkillHubCard from "./agentSkill";
export default function HubItemCard({ type, item }) {
switch (type) {
case "systemPrompts":
return <SystemPromptHubCard item={item} />;
case "slashCommands":
return <SlashCommandHubCard item={item} />;
case "agentSkills":
return <AgentSkillHubCard item={item} />;
default:
return <GenericHubCard item={item} />;
}
}

View file

@ -0,0 +1,45 @@
import truncate from "truncate";
import { Link } from "react-router-dom";
import paths from "@/utils/paths";
import { VisibilityIcon } from "./generic";
export default function SlashCommandHubCard({ item }) {
return (
<>
<Link
key={item.id}
to={paths.communityHub.importItem(item.importId)}
className="bg-black/70 light:bg-slate-100 rounded-lg p-3 hover:bg-black/60 light:hover:bg-slate-200 transition-all duration-200 cursor-pointer group border border-transparent hover:border-slate-400"
>
<div className="flex gap-x-2 items-center">
<p className="text-white text-sm font-medium">{item.name}</p>
<VisibilityIcon visibility={item.visibility} />
</div>
<div className="flex flex-col gap-2">
<p className="text-white/60 text-xs mt-1">{item.description}</p>
<label className="text-white/60 text-xs font-semibold mt-4">
Command
</label>
<p className="text-white/60 text-xs bg-zinc-900 light:bg-slate-200 px-2 py-1 rounded-md font-mono border border-slate-800 light:border-slate-300">
{item.command}
</p>
<label className="text-white/60 text-xs font-semibold mt-4">
Prompt
</label>
<p className="text-white/60 text-xs bg-zinc-900 light:bg-slate-200 px-2 py-1 rounded-md font-mono border border-slate-800 light:border-slate-300">
{truncate(item.prompt, 90)}
</p>
</div>
<div className="flex justify-end mt-2">
<Link
to={paths.communityHub.importItem(item.importId)}
className="text-primary-button hover:text-primary-button/80 text-sm font-medium px-3 py-1.5 rounded-md bg-black/30 light:bg-slate-200 group-hover:bg-black/50 light:group-hover:bg-slate-300 transition-all"
>
Import
</Link>
</div>
</Link>
</>
);
}

View file

@ -0,0 +1,38 @@
import truncate from "truncate";
import { Link } from "react-router-dom";
import paths from "@/utils/paths";
import { VisibilityIcon } from "./generic";
export default function SystemPromptHubCard({ item }) {
return (
<>
<Link
key={item.id}
to={paths.communityHub.importItem(item.importId)}
className="bg-black/70 light:bg-slate-100 rounded-lg p-3 hover:bg-black/60 light:hover:bg-slate-200 transition-all duration-200 cursor-pointer group border border-transparent hover:border-slate-400"
>
<div className="flex gap-x-2 items-center">
<p className="text-white text-sm font-medium">{item.name}</p>
<VisibilityIcon visibility={item.visibility} />
</div>
<div className="flex flex-col gap-2">
<p className="text-white/60 text-xs mt-1">{item.description}</p>
<label className="text-white/60 text-xs font-semibold mt-4">
Prompt
</label>
<p className="text-white/60 text-xs bg-zinc-900 light:bg-slate-200 px-2 py-1 rounded-md font-mono border border-slate-800 light:border-slate-300">
{truncate(item.prompt, 90)}
</p>
</div>
<div className="flex justify-end mt-2">
<Link
to={paths.communityHub.importItem(item.importId)}
className="text-primary-button hover:text-primary-button/80 text-sm font-medium px-3 py-1.5 rounded-md bg-black/30 light:bg-slate-200 group-hover:bg-black/50 light:group-hover:bg-slate-300 transition-all"
>
Import
</Link>
</div>
</Link>
</>
);
}

View file

@ -0,0 +1,135 @@
import { useEffect, useState } from "react";
import CommunityHub from "@/models/communityHub";
import paths from "@/utils/paths";
import HubItemCard from "./HubItemCard";
import * as Skeleton from "react-loading-skeleton";
import "react-loading-skeleton/dist/skeleton.css";
import { readableType, typeToPath } from "../../utils";
const DEFAULT_EXPLORE_ITEMS = {
agentSkills: { items: [], hasMore: false, totalCount: 0 },
systemPrompts: { items: [], hasMore: false, totalCount: 0 },
slashCommands: { items: [], hasMore: false, totalCount: 0 },
};
function useCommunityHubExploreItems() {
const [loading, setLoading] = useState(true);
const [exploreItems, setExploreItems] = useState(DEFAULT_EXPLORE_ITEMS);
useEffect(() => {
const fetchData = async () => {
setLoading(true);
try {
const { success, result } = await CommunityHub.fetchExploreItems();
if (success) setExploreItems(result || DEFAULT_EXPLORE_ITEMS);
} catch (error) {
console.error("Error fetching data:", error);
} finally {
setLoading(false);
}
};
fetchData();
}, []);
return { loading, exploreItems };
}
export default function HubItems() {
const { loading, exploreItems } = useCommunityHubExploreItems();
return (
<div className="w-full flex flex-col gap-y-1 pb-6 pt-6">
<div className="flex flex-col gap-y-2 mb-4">
<p className="text-base font-semibold text-theme-text-primary">
Recently Added on AnythingLLM Community Hub
</p>
<p className="text-xs text-theme-text-secondary">
Explore the latest additions to the AnythingLLM Community Hub
</p>
</div>
<HubCategory loading={loading} exploreItems={exploreItems} />
</div>
);
}
function HubCategory({ loading, exploreItems }) {
if (loading) return <HubItemCardSkeleton />;
return (
<div className="flex flex-col gap-4">
{Object.keys(exploreItems).map((type) => {
const path = typeToPath(type);
if (exploreItems[type].items.length === 0) return null;
return (
<div key={type} className="rounded-lg w-full">
<div className="flex justify-between items-center">
<h3 className="text-theme-text-primary capitalize font-medium mb-3">
{readableType(type)}
</h3>
{exploreItems[type].hasMore && (
<a
href={paths.communityHub.viewMoreOfType(path)}
target="_blank"
rel="noopener noreferrer"
className="text-primary-button hover:text-primary-button/80 text-sm"
>
Explore More
</a>
)}
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-2">
{exploreItems[type].items.map((item) => (
<HubItemCard key={item.id} type={type} item={item} />
))}
</div>
</div>
);
})}
</div>
);
}
export function HubItemCardSkeleton() {
return (
<div className="flex flex-col gap-4">
<div className="rounded-lg w-full">
<div className="flex justify-between items-center">
<Skeleton.default
height="40px"
width="300px"
highlightColor="var(--theme-settings-input-active)"
baseColor="var(--theme-settings-input-bg)"
count={1}
/>
</div>
<Skeleton.default
height="200px"
width="300px"
highlightColor="var(--theme-settings-input-active)"
baseColor="var(--theme-settings-input-bg)"
count={4}
className="rounded-lg"
containerClassName="flex flex-wrap gap-2 mt-1"
/>
</div>
<div className="rounded-lg w-full">
<div className="flex justify-between items-center">
<Skeleton.default
height="40px"
width="300px"
highlightColor="var(--theme-settings-input-active)"
baseColor="var(--theme-settings-input-bg)"
count={1}
/>
</div>
<Skeleton.default
height="200px"
width="300px"
highlightColor="var(--theme-settings-input-active)"
baseColor="var(--theme-settings-input-bg)"
count={4}
className="rounded-lg"
containerClassName="flex flex-wrap gap-2 mt-1"
/>
</div>
</div>
);
}

View file

@ -0,0 +1,29 @@
import Sidebar from "@/components/SettingsSidebar";
import { isMobile } from "react-device-detect";
import HubItems from "./HubItems";
export default function CommunityHub() {
return (
<div className="w-screen h-screen overflow-hidden bg-theme-bg-container flex">
<Sidebar />
<div
style={{ height: isMobile ? "100%" : "calc(100% - 32px)" }}
className="relative md:ml-[2px] md:mr-[16px] md:my-[16px] md:rounded-[16px] bg-theme-bg-secondary w-full h-full overflow-y-scroll p-4 md:p-0"
>
<div className="flex flex-col w-full px-1 md:pl-6 md:pr-[86px] md:py-6 py-16">
<div className="w-full flex flex-col gap-y-1 pb-6 border-white light:border-theme-sidebar-border border-b-2 border-opacity-10">
<div className="items-center">
<p className="text-lg leading-6 font-bold text-theme-text-primary">
Community Hub
</p>
</div>
<p className="text-xs leading-[18px] font-base text-theme-text-secondary">
Share and collaborate with the AnythingLLM community.
</p>
</div>
<HubItems />
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,37 @@
/**
* Convert a type to a readable string for the community hub.
* @param {("agentSkills" | "agentSkill" | "systemPrompts" | "systemPrompt" | "slashCommands" | "slashCommand")} type
* @returns {string}
*/
export function readableType(type) {
switch (type) {
case "agentSkills":
case "agentSkill":
return "Agent Skills";
case "systemPrompt":
case "systemPrompts":
return "System Prompts";
case "slashCommand":
case "slashCommands":
return "Slash Commands";
}
}
/**
* Convert a type to a path for the community hub.
* @param {("agentSkill" | "agentSkills" | "systemPrompt" | "systemPrompts" | "slashCommand" | "slashCommands")} type
* @returns {string}
*/
export function typeToPath(type) {
switch (type) {
case "agentSkill":
case "agentSkills":
return "agent-skills";
case "systemPrompt":
case "systemPrompts":
return "system-prompts";
case "slashCommand":
case "slashCommands":
return "slash-commands";
}
}

View file

@ -142,6 +142,38 @@ export default {
return `/settings/beta-features`;
},
},
communityHub: {
website: () => {
return import.meta.env.DEV
? `http://localhost:5173`
: `https://hub.anythingllm.com`;
},
/**
* View more items of a given type on the community hub.
* @param {string} type - The type of items to view more of. Should be kebab-case.
* @returns {string} The path to view more items of the given type.
*/
viewMoreOfType: function (type) {
return `${this.website()}/list/${type}`;
},
trending: () => {
return `/settings/community-hub/trending`;
},
authentication: () => {
return `/settings/community-hub/authentication`;
},
importItem: (importItemId) => {
return `/settings/community-hub/import-item${importItemId ? `?id=${importItemId}` : ""}`;
},
profile: function (username) {
if (username) return `${this.website()}/u/${username}`;
return `${this.website()}/me`;
},
noPrivateItems: () => {
return "https://docs.anythingllm.com/community-hub/faq#no-private-items";
},
},
experimental: {
liveDocumentSync: {
manage: () => `/settings/beta-features/live-document-sync/manage`,

View file

@ -0,0 +1,186 @@
const { SystemSettings } = require("../models/systemSettings");
const { validatedRequest } = require("../utils/middleware/validatedRequest");
const { reqBody } = require("../utils/http");
const { CommunityHub } = require("../models/communityHub");
const {
communityHubDownloadsEnabled,
communityHubItem,
} = require("../utils/middleware/communityHubDownloadsEnabled");
const { EventLogs } = require("../models/eventLogs");
const { Telemetry } = require("../models/telemetry");
const {
flexUserRoleValid,
ROLES,
} = require("../utils/middleware/multiUserProtected");
function communityHubEndpoints(app) {
if (!app) return;
app.get(
"/community-hub/settings",
[validatedRequest, flexUserRoleValid([ROLES.admin])],
async (_, response) => {
try {
const { connectionKey } = await SystemSettings.hubSettings();
response.status(200).json({ success: true, connectionKey });
} catch (error) {
console.error(error);
response.status(500).json({ success: false, error: error.message });
}
}
);
app.post(
"/community-hub/settings",
[validatedRequest, flexUserRoleValid([ROLES.admin])],
async (request, response) => {
try {
const data = reqBody(request);
const result = await SystemSettings.updateSettings(data);
if (result.error) throw new Error(result.error);
response.status(200).json({ success: true, error: null });
} catch (error) {
console.error(error);
response.status(500).json({ success: false, error: error.message });
}
}
);
app.get(
"/community-hub/explore",
[validatedRequest, flexUserRoleValid([ROLES.admin])],
async (_, response) => {
try {
const exploreItems = await CommunityHub.fetchExploreItems();
response.status(200).json({ success: true, result: exploreItems });
} catch (error) {
console.error(error);
response.status(500).json({
success: false,
result: null,
error: error.message,
});
}
}
);
app.post(
"/community-hub/item",
[validatedRequest, flexUserRoleValid([ROLES.admin]), communityHubItem],
async (_request, response) => {
try {
response.status(200).json({
success: true,
item: response.locals.bundleItem,
error: null,
});
} catch (error) {
console.error(error);
response.status(500).json({
success: false,
item: null,
error: error.message,
});
}
}
);
/**
* Apply an item to the AnythingLLM instance. Used for simple items like slash commands and system prompts.
*/
app.post(
"/community-hub/apply",
[validatedRequest, flexUserRoleValid([ROLES.admin]), communityHubItem],
async (request, response) => {
try {
const { options = {} } = reqBody(request);
const item = response.locals.bundleItem;
const { error: applyError } = await CommunityHub.applyItem(item, {
...options,
currentUser: response.locals?.user,
});
if (applyError) throw new Error(applyError);
await Telemetry.sendTelemetry("community_hub_import", {
itemType: response.locals.bundleItem.itemType,
visibility: response.locals.bundleItem.visibility,
});
await EventLogs.logEvent(
"community_hub_import",
{
itemId: response.locals.bundleItem.id,
itemType: response.locals.bundleItem.itemType,
},
response.locals?.user?.id
);
response.status(200).json({ success: true, error: null });
} catch (error) {
console.error(error);
response.status(500).json({ success: false, error: error.message });
}
}
);
/**
* Import a bundle item to the AnythingLLM instance by downloading the zip file and importing it.
* or whatever the item type requires. This is not used if the item is a simple text responses like
* slash commands or system prompts.
*/
app.post(
"/community-hub/import",
[
validatedRequest,
flexUserRoleValid([ROLES.admin]),
communityHubItem,
communityHubDownloadsEnabled,
],
async (_, response) => {
try {
const { error: importError } = await CommunityHub.importBundleItem({
url: response.locals.bundleUrl,
item: response.locals.bundleItem,
});
if (importError) throw new Error(importError);
await Telemetry.sendTelemetry("community_hub_import", {
itemType: response.locals.bundleItem.itemType,
visibility: response.locals.bundleItem.visibility,
});
await EventLogs.logEvent(
"community_hub_import",
{
itemId: response.locals.bundleItem.id,
itemType: response.locals.bundleItem.itemType,
},
response.locals?.user?.id
);
response.status(200).json({ success: true, error: null });
} catch (error) {
console.error(error);
response.status(500).json({
success: false,
error: error.message,
});
}
}
);
app.get(
"/community-hub/items",
[validatedRequest, flexUserRoleValid([ROLES.admin])],
async (_, response) => {
try {
const { connectionKey } = await SystemSettings.hubSettings();
const items = await CommunityHub.fetchUserItems(connectionKey);
response.status(200).json({ success: true, ...items });
} catch (error) {
console.error(error);
response.status(500).json({ success: false, error: error.message });
}
}
);
}
module.exports = { communityHubEndpoints };

View file

@ -45,6 +45,21 @@ function importedAgentPluginEndpoints(app) {
}
}
);
app.delete(
"/experimental/agent-plugins/:hubId",
[validatedRequest, flexUserRoleValid([ROLES.admin])],
async (request, response) => {
try {
const { hubId } = request.params;
const result = ImportedPlugin.deletePlugin(hubId);
response.status(200).json(result);
} catch (e) {
console.error(e);
response.status(500).end();
}
}
);
}
module.exports = { importedAgentPluginEndpoints };

View file

@ -25,6 +25,7 @@ const { documentEndpoints } = require("./endpoints/document");
const { agentWebsocket } = require("./endpoints/agentWebsocket");
const { experimentalEndpoints } = require("./endpoints/experimental");
const { browserExtensionEndpoints } = require("./endpoints/browserExtension");
const { communityHubEndpoints } = require("./endpoints/communityHub");
const app = express();
const apiRouter = express.Router();
const FILE_LIMIT = "3GB";
@ -59,6 +60,7 @@ documentEndpoints(apiRouter);
agentWebsocket(apiRouter);
experimentalEndpoints(apiRouter);
developerEndpoints(app, apiRouter);
communityHubEndpoints(apiRouter);
// Externally facing embedder endpoints
embeddedEndpoints(apiRouter);

View file

@ -0,0 +1,177 @@
const ImportedPlugin = require("../utils/agents/imported");
/**
* An interface to the AnythingLLM Community Hub external API.
*/
const CommunityHub = {
importPrefix: "allm-community-id",
apiBase:
process.env.NODE_ENV === "development"
? "http://127.0.0.1:5001/anythingllm-hub/us-central1/external/v1"
: "https://hub.external.anythingllm.com/v1",
/**
* Validate an import ID and return the entity type and ID.
* @param {string} importId - The import ID to validate.
* @returns {{entityType: string | null, entityId: string | null}}
*/
validateImportId: function (importId) {
if (
!importId ||
!importId.startsWith(this.importPrefix) ||
importId.split(":").length !== 3
)
return { entityType: null, entityId: null };
const [_, entityType, entityId] = importId.split(":");
if (!entityType || !entityId) return { entityType: null, entityId: null };
return {
entityType: String(entityType).trim(),
entityId: String(entityId).trim(),
};
},
/**
* Fetch the explore items from the community hub that are publicly available.
* @returns {Promise<{agentSkills: {items: [], hasMore: boolean, totalCount: number}, systemPrompts: {items: [], hasMore: boolean, totalCount: number}, slashCommands: {items: [], hasMore: boolean, totalCount: number}}>}
*/
fetchExploreItems: async function () {
return await fetch(`${this.apiBase}/explore`, {
method: "GET",
})
.then((response) => response.json())
.catch((error) => {
console.error("Error fetching explore items:", error);
return {
agentSkills: {
items: [],
hasMore: false,
totalCount: 0,
},
systemPrompts: {
items: [],
hasMore: false,
totalCount: 0,
},
slashCommands: {
items: [],
hasMore: false,
totalCount: 0,
},
};
});
},
/**
* Fetch a bundle item from the community hub.
* Bundle items are entities that require a downloadURL to be fetched from the community hub.
* so we can unzip and import them to the AnythingLLM instance.
* @param {string} importId - The import ID of the item.
* @returns {Promise<{url: string | null, item: object | null, error: string | null}>}
*/
getBundleItem: async function (importId) {
const { entityType, entityId } = this.validateImportId(importId);
if (!entityType || !entityId)
return { item: null, error: "Invalid import ID" };
const { SystemSettings } = require("./systemSettings");
const { connectionKey } = await SystemSettings.hubSettings();
const { url, item, error } = await fetch(
`${this.apiBase}/${entityType}/${entityId}/pull`,
{
method: "GET",
headers: {
"Content-Type": "application/json",
...(connectionKey
? { Authorization: `Bearer ${connectionKey}` }
: {}),
},
}
)
.then((response) => response.json())
.catch((error) => {
console.error(
`Error fetching bundle item for import ID ${importId}:`,
error
);
return { url: null, item: null, error: error.message };
});
return { url, item, error };
},
/**
* Apply an item to the AnythingLLM instance. Used for simple items like slash commands and system prompts.
* @param {object} item - The item to apply.
* @param {object} options - Additional options for applying the item.
* @param {object|null} options.currentUser - The current user object.
* @returns {Promise<{success: boolean, error: string | null}>}
*/
applyItem: async function (item, options = {}) {
if (!item) return { success: false, error: "Item is required" };
if (item.itemType === "system-prompt") {
if (!options?.workspaceSlug)
return { success: false, error: "Workspace slug is required" };
const { Workspace } = require("./workspace");
const workspace = await Workspace.get({
slug: String(options.workspaceSlug),
});
if (!workspace) return { success: false, error: "Workspace not found" };
await Workspace.update(workspace.id, { openAiPrompt: item.prompt });
return { success: true, error: null };
}
if (item.itemType === "slash-command") {
const { SlashCommandPresets } = require("./slashCommandsPresets");
await SlashCommandPresets.create(options?.currentUser?.id, {
command: SlashCommandPresets.formatCommand(String(item.command)),
prompt: String(item.prompt),
description: String(item.description),
});
return { success: true, error: null };
}
return {
success: false,
error: "Unsupported item type. Nothing to apply.",
};
},
/**
* Import a bundle item to the AnythingLLM instance by downloading the zip file and importing it.
* or whatever the item type requires.
* @param {{url: string, item: object}} params
* @returns {Promise<{success: boolean, error: string | null}>}
*/
importBundleItem: async function ({ url, item }) {
if (item.itemType === "agent-skill") {
const { success, error } =
await ImportedPlugin.importCommunityItemFromUrl(url, item);
return { success, error };
}
return {
success: false,
error: "Unsupported item type. Nothing to import.",
};
},
fetchUserItems: async function (connectionKey) {
if (!connectionKey) return { createdByMe: {}, teamItems: [] };
return await fetch(`${this.apiBase}/items`, {
method: "GET",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${connectionKey}`,
},
})
.then((response) => response.json())
.catch((error) => {
console.error("Error fetching user items:", error);
return { createdByMe: {}, teamItems: [] };
});
},
};
module.exports = { CommunityHub };

View file

@ -39,6 +39,18 @@ const SlashCommandPresets = {
// Command + userId must be unique combination.
create: async function (userId = null, presetData = {}) {
try {
const existingPreset = await this.get({
userId: userId ? Number(userId) : null,
command: String(presetData.command),
});
if (existingPreset) {
console.log(
"SlashCommandPresets.create - preset already exists - will not create"
);
return existingPreset;
}
const preset = await prisma.slash_command_presets.create({
data: {
...presetData,

View file

@ -14,7 +14,7 @@ function isNullOrNaN(value) {
}
const SystemSettings = {
protectedFields: ["multi_user_mode"],
protectedFields: ["multi_user_mode", "hub_api_key"],
publicFields: [
"footer_data",
"support_email",
@ -49,6 +49,9 @@ const SystemSettings = {
// beta feature flags
"experimental_live_file_sync",
// Hub settings
"hub_api_key",
],
validations: {
footer_data: (updates) => {
@ -165,6 +168,10 @@ const SystemSettings = {
new MetaGenerator().clearConfig();
}
},
hub_api_key: (apiKey) => {
if (!apiKey) return null;
return String(apiKey);
},
},
currentSettings: async function () {
const { hasVectorCachedFiles } = require("../utils/files");
@ -563,6 +570,22 @@ const SystemSettings = {
?.value === "enabled",
};
},
/**
* Get user configured Community Hub Settings
* Connection key is used to authenticate with the Community Hub API
* for your account.
* @returns {Promise<{connectionKey: string}>}
*/
hubSettings: async function () {
try {
const hubKey = await this.get({ label: "hub_api_key" });
return { connectionKey: hubKey?.value || null };
} catch (error) {
console.error(error.message);
return { connectionKey: null };
}
},
};
function mergeConnections(existingConnections = [], updates = []) {

View file

@ -38,6 +38,7 @@
"@qdrant/js-client-rest": "^1.9.0",
"@xenova/transformers": "^2.14.0",
"@zilliz/milvus2-sdk-node": "^2.3.5",
"adm-zip": "^0.5.16",
"bcrypt": "^5.1.0",
"body-parser": "^1.20.2",
"chalk": "^4",
@ -92,8 +93,8 @@
"flow-remove-types": "^2.217.1",
"globals": "^13.21.0",
"hermes-eslint": "^0.15.0",
"nodemon": "^2.0.22",
"node-html-markdown": "^1.3.0",
"nodemon": "^2.0.22",
"prettier": "^3.0.3"
}
}
}

View file

@ -2,10 +2,12 @@ const fs = require("fs");
const path = require("path");
const { safeJsonParse } = require("../http");
const { isWithin, normalizePath } = require("../files");
const { CollectorApi } = require("../collectorApi");
const pluginsPath =
process.env.NODE_ENV === "development"
? path.resolve(__dirname, "../../storage/plugins/agent-skills")
: path.resolve(process.env.STORAGE_DIR, "plugins", "agent-skills");
const sharedWebScraper = new CollectorApi();
class ImportedPlugin {
constructor(config) {
@ -124,6 +126,20 @@ class ImportedPlugin {
return updatedConfig;
}
/**
* Deletes a plugin. Removes the entire folder of the object.
* @param {string} hubId - The hub ID of the plugin.
* @returns {boolean} - True if the plugin was deleted, false otherwise.
*/
static deletePlugin(hubId) {
if (!hubId) throw new Error("No plugin hubID passed.");
const pluginFolder = path.resolve(pluginsPath, normalizePath(hubId));
if (!this.isValidLocation(pluginFolder)) return;
fs.rmSync(pluginFolder, { recursive: true });
return true;
}
/**
/**
* Validates if the handler.js file exists for the given plugin.
* @param {string} hubId - The hub ID of the plugin.
@ -170,6 +186,8 @@ class ImportedPlugin {
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.
runtime: "docker",
webScraper: sharedWebScraper,
examples: this.config.examples ?? [],
parameters: {
$schema: "http://json-schema.org/draft-07/schema#",
@ -182,6 +200,107 @@ class ImportedPlugin {
},
};
}
/**
* Imports a community item from a URL.
* The community item is a zip file that contains a plugin.json file and handler.js file.
* This function will unzip the file and import the plugin into the agent-skills folder
* based on the hubId found in the plugin.json file.
* The zip file will be downloaded to the pluginsPath folder and then unzipped and finally deleted.
* @param {string} url - The signed URL of the community item zip file.
* @param {object} item - The community item.
* @returns {Promise<object>} - The result of the import.
*/
static async importCommunityItemFromUrl(url, item) {
this.checkPluginFolderExists();
const hubId = item.id;
if (!hubId) return { success: false, error: "No hubId passed to import." };
const zipFilePath = path.resolve(pluginsPath, `${item.id}.zip`);
const pluginFile = item.manifest.files.find(
(file) => file.name === "plugin.json"
);
if (!pluginFile)
return {
success: false,
error: "No plugin.json file found in manifest.",
};
const pluginFolder = path.resolve(pluginsPath, normalizePath(hubId));
if (fs.existsSync(pluginFolder))
console.log(
"ImportedPlugin.importCommunityItemFromUrl - plugin folder already exists - will overwrite"
);
try {
const protocol = new URL(url).protocol.replace(":", "");
const httpLib = protocol === "https" ? require("https") : require("http");
const downloadZipFile = new Promise(async (resolve) => {
try {
console.log(
"ImportedPlugin.importCommunityItemFromUrl - downloading asset from ",
new URL(url).origin
);
const zipFile = fs.createWriteStream(zipFilePath);
const request = httpLib.get(url, function (response) {
response.pipe(zipFile);
zipFile.on("finish", () => {
console.log(
"ImportedPlugin.importCommunityItemFromUrl - downloaded zip file"
);
resolve(true);
});
});
request.on("error", (error) => {
console.error(
"ImportedPlugin.importCommunityItemFromUrl - error downloading zip file: ",
error
);
resolve(false);
});
} catch (error) {
console.error(
"ImportedPlugin.importCommunityItemFromUrl - error downloading zip file: ",
error
);
resolve(false);
}
});
const success = await downloadZipFile;
if (!success)
return { success: false, error: "Failed to download zip file." };
// Unzip the file to the plugin folder
// Note: https://github.com/cthackers/adm-zip?tab=readme-ov-file#electron-original-fs
const AdmZip = require("adm-zip");
const zip = new AdmZip(zipFilePath);
zip.extractAllTo(pluginFolder);
// We want to make sure specific keys are set to the proper values for
// plugin.json so we read and overwrite the file with the proper values.
const pluginJsonPath = path.resolve(pluginFolder, "plugin.json");
const pluginJson = safeJsonParse(fs.readFileSync(pluginJsonPath, "utf8"));
pluginJson.active = false;
pluginJson.hubId = hubId;
fs.writeFileSync(pluginJsonPath, JSON.stringify(pluginJson, null, 2));
console.log(
`ImportedPlugin.importCommunityItemFromUrl - successfully imported plugin to agent-skills/${hubId}`
);
return { success: true, error: null };
} catch (error) {
console.error(
"ImportedPlugin.importCommunityItemFromUrl - error: ",
error
);
return { success: false, error: error.message };
} finally {
if (fs.existsSync(zipFilePath)) fs.unlinkSync(zipFilePath);
}
}
}
module.exports = ImportedPlugin;

View file

@ -939,6 +939,8 @@ function dumpENV() {
"DISABLE_VIEW_CHAT_HISTORY",
// Simple SSO
"SIMPLE_SSO_ENABLED",
// Community Hub
"COMMUNITY_HUB_BUNDLE_DOWNLOADS_ENABLED",
];
// Simple sanitization of each value to prevent ENV injection via newline or quote escaping.

View file

@ -0,0 +1,77 @@
const { CommunityHub } = require("../../models/communityHub");
const { reqBody } = require("../http");
/**
* ### Must be called after `communityHubItem`
* Checks if community hub bundle downloads are enabled. The reason this functionality is disabled
* by default is that since AgentSkills, Workspaces, and DataConnectors are all imported from the
* community hub via unzipping a bundle - it would be possible for a malicious user to craft and
* download a malicious bundle and import it into their own hosted instance. To avoid this, this
* functionality is disabled by default and must be enabled manually by the system administrator.
*
* On hosted systems, this would not be an issue since the user cannot modify this setting, but those
* who self-host can still unlock this feature manually by setting the environment variable
* which would require someone who likely has the capacity to understand the risks and the
* implications of importing unverified items that can run code on their system, container, or instance.
* @see {@link https://docs.anythingllm.com/docs/community-hub/import}
* @param {import("express").Request} request
* @param {import("express").Response} response
* @param {import("express").NextFunction} next
* @returns {void}
*/
function communityHubDownloadsEnabled(request, response, next) {
if (!("COMMUNITY_HUB_BUNDLE_DOWNLOADS_ENABLED" in process.env)) {
return response.status(422).json({
error:
"Community Hub bundle downloads are not enabled. The system administrator must enable this feature manually to allow this instance to download these types of items. See https://docs.anythingllm.com/configuration#anythingllm-hub-agent-skills",
});
}
// If the admin specifically did not set the system to `allow_all` then downloads are limited to verified items or private items only.
// This is to prevent users from downloading unverified items and importing them into their own instance without understanding the risks.
const item = response.locals.bundleItem;
if (
!item.verified &&
item.visibility !== "private" &&
process.env.COMMUNITY_HUB_BUNDLE_DOWNLOADS_ENABLED !== "allow_all"
) {
return response.status(422).json({
error:
"Community hub bundle downloads are limited to verified public items or private team items only. Please contact the system administrator to review or modify this setting. See https://docs.anythingllm.com/configuration#anythingllm-hub-agent-skills",
});
}
next();
}
/**
* Fetch the bundle item from the community hub.
* Sets `response.locals.bundleItem` and `response.locals.bundleUrl`.
*/
async function communityHubItem(request, response, next) {
const { importId } = reqBody(request);
if (!importId)
return response.status(500).json({
success: false,
error: "Import ID is required",
});
const {
url,
item,
error: fetchError,
} = await CommunityHub.getBundleItem(importId);
if (fetchError)
return response.status(500).json({
success: false,
error: fetchError,
});
response.locals.bundleItem = item;
response.locals.bundleUrl = url;
next();
}
module.exports = {
communityHubItem,
communityHubDownloadsEnabled,
};

View file

@ -2273,6 +2273,11 @@ acorn@^8.9.0:
resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.11.3.tgz#71e0b14e13a4ec160724b38fb7b0f233b1b81d7a"
integrity sha512-Y9rRfJG5jcKOE0CLisYbojUjIrIEE7AGMzA/Sm4BslANhbS+cDMpgBdcPT91oJ7OuJ9hYJBx59RjbhxVnrF8Xg==
adm-zip@^0.5.16:
version "0.5.16"
resolved "https://registry.yarnpkg.com/adm-zip/-/adm-zip-0.5.16.tgz#0b5e4c779f07dedea5805cdccb1147071d94a909"
integrity sha512-TGw5yVi4saajsSEgz25grObGHEUaDrniwvA2qwSC060KfqGPdglhvPMA2lPIoxs3PQIItj2iag35fONcQqgUaQ==
agent-base@6:
version "6.0.2"
resolved "https://registry.yarnpkg.com/agent-base/-/agent-base-6.0.2.tgz#49fff58577cfee3f37176feab4c22e00f86d7f77"