diff --git a/.github/workflows/dev-build.yaml b/.github/workflows/dev-build.yaml index 85886fdb517467547fd0594bc52ca65d74cdd664..09d65ea1c7a4c15d4d57e67c9799ab73afddb5bb 100644 --- a/.github/workflows/dev-build.yaml +++ b/.github/workflows/dev-build.yaml @@ -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/*' diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index e692cd30366f422eb61bec65f2e97b4bb35384de..9ee1c5d8702ada79f0a210bfc443117727676a94 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -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> diff --git a/frontend/src/components/SettingsSidebar/index.jsx b/frontend/src/components/SettingsSidebar/index.jsx index bb2d32b2d481e8048a606aa35cc4cc177eb081c8..a20c2feeb1400a1a1ba4bd0a921c7c084de89a74 100644 --- a/frontend/src/components/SettingsSidebar/index.jsx +++ b/frontend/src/components/SettingsSidebar/index.jsx @@ -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" />} diff --git a/frontend/src/components/WorkspaceChat/index.jsx b/frontend/src/components/WorkspaceChat/index.jsx index bff792573038a566665c3747dd51ed1a753f6ae8..90fafe5d039ea6cc4b572653f41ac87004642bf2 100644 --- a/frontend/src/components/WorkspaceChat/index.jsx +++ b/frontend/src/components/WorkspaceChat/index.jsx @@ -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; diff --git a/frontend/src/models/communityHub.js b/frontend/src/models/communityHub.js new file mode 100644 index 0000000000000000000000000000000000000000..517adbc80dbf92eaf065e34399222d39338c0f49 --- /dev/null +++ b/frontend/src/models/communityHub.js @@ -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; diff --git a/frontend/src/models/experimental/agentPlugins.js b/frontend/src/models/experimental/agentPlugins.js index 9a544d5f12f4d06cacdda594335fbd328f7eab8b..092a9a3203d423bb816a43a1bae41f2cf4cab94e 100644 --- a/frontend/src/models/experimental/agentPlugins.js +++ b/frontend/src/models/experimental/agentPlugins.js @@ -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; diff --git a/frontend/src/pages/Admin/Agents/Imported/ImportedSkillConfig/index.jsx b/frontend/src/pages/Admin/Agents/Imported/ImportedSkillConfig/index.jsx index 4c99da725aeec5a137737a210ae9f27be7bc5f28..a68ff5b16c1c3723a5bc77d251039a798e30656f 100644 --- a/frontend/src/pages/Admin/Agents/Imported/ImportedSkillConfig/index.jsx +++ b/frontend/src/pages/Admin/Agents/Imported/ImportedSkillConfig/index.jsx @@ -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> + ); +} diff --git a/frontend/src/pages/GeneralSettings/CommunityHub/Authentication/UserItems/index.jsx b/frontend/src/pages/GeneralSettings/CommunityHub/Authentication/UserItems/index.jsx new file mode 100644 index 0000000000000000000000000000000000000000..03787b02a20cb56934f7e283c5b4760370f9ef08 --- /dev/null +++ b/frontend/src/pages/GeneralSettings/CommunityHub/Authentication/UserItems/index.jsx @@ -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> + ); +} diff --git a/frontend/src/pages/GeneralSettings/CommunityHub/Authentication/index.jsx b/frontend/src/pages/GeneralSettings/CommunityHub/Authentication/index.jsx new file mode 100644 index 0000000000000000000000000000000000000000..1f54c76b45d57d3efca348e6633bd8e8da20757c --- /dev/null +++ b/frontend/src/pages/GeneralSettings/CommunityHub/Authentication/index.jsx @@ -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> + ); +} diff --git a/frontend/src/pages/GeneralSettings/CommunityHub/Authentication/useUserItems.js b/frontend/src/pages/GeneralSettings/CommunityHub/Authentication/useUserItems.js new file mode 100644 index 0000000000000000000000000000000000000000..3222f84740cc04b9018a6c76ce76611e3123cccd --- /dev/null +++ b/frontend/src/pages/GeneralSettings/CommunityHub/Authentication/useUserItems.js @@ -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 }; +} diff --git a/frontend/src/pages/GeneralSettings/CommunityHub/ImportItem/Steps/Completed/index.jsx b/frontend/src/pages/GeneralSettings/CommunityHub/ImportItem/Steps/Completed/index.jsx new file mode 100644 index 0000000000000000000000000000000000000000..38c1aba8d971577891b60c1b567cc770ee489766 --- /dev/null +++ b/frontend/src/pages/GeneralSettings/CommunityHub/ImportItem/Steps/Completed/index.jsx @@ -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> + ); +} diff --git a/frontend/src/pages/GeneralSettings/CommunityHub/ImportItem/Steps/Introduction/index.jsx b/frontend/src/pages/GeneralSettings/CommunityHub/ImportItem/Steps/Introduction/index.jsx new file mode 100644 index 0000000000000000000000000000000000000000..5456d542bcfa70735357325b903019a362e96326 --- /dev/null +++ b/frontend/src/pages/GeneralSettings/CommunityHub/ImportItem/Steps/Introduction/index.jsx @@ -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 → + </CTAButton> + </div> + </div> + </div> + ); +} diff --git a/frontend/src/pages/GeneralSettings/CommunityHub/ImportItem/Steps/PullAndReview/HubItem/AgentSkill.jsx b/frontend/src/pages/GeneralSettings/CommunityHub/ImportItem/Steps/PullAndReview/HubItem/AgentSkill.jsx new file mode 100644 index 0000000000000000000000000000000000000000..b3ca6dd96aeaba5bf449c59ea417b1818b2a4a46 --- /dev/null +++ b/frontend/src/pages/GeneralSettings/CommunityHub/ImportItem/Steps/PullAndReview/HubItem/AgentSkill.jsx @@ -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 → + </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> + ); +} diff --git a/frontend/src/pages/GeneralSettings/CommunityHub/ImportItem/Steps/PullAndReview/HubItem/SlashCommand.jsx b/frontend/src/pages/GeneralSettings/CommunityHub/ImportItem/Steps/PullAndReview/HubItem/SlashCommand.jsx new file mode 100644 index 0000000000000000000000000000000000000000..aa33a777bd1208aba66555d11126e285463320be --- /dev/null +++ b/frontend/src/pages/GeneralSettings/CommunityHub/ImportItem/Steps/PullAndReview/HubItem/SlashCommand.jsx @@ -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> + ); +} diff --git a/frontend/src/pages/GeneralSettings/CommunityHub/ImportItem/Steps/PullAndReview/HubItem/SystemPrompt.jsx b/frontend/src/pages/GeneralSettings/CommunityHub/ImportItem/Steps/PullAndReview/HubItem/SystemPrompt.jsx new file mode 100644 index 0000000000000000000000000000000000000000..fa12298bc2fe821f39defc8dcf3ebba63c48f25e --- /dev/null +++ b/frontend/src/pages/GeneralSettings/CommunityHub/ImportItem/Steps/PullAndReview/HubItem/SystemPrompt.jsx @@ -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> + ); +} diff --git a/frontend/src/pages/GeneralSettings/CommunityHub/ImportItem/Steps/PullAndReview/HubItem/Unknown.jsx b/frontend/src/pages/GeneralSettings/CommunityHub/ImportItem/Steps/PullAndReview/HubItem/Unknown.jsx new file mode 100644 index 0000000000000000000000000000000000000000..f96d57619a4cad7319434ded785a298a6fd5648d --- /dev/null +++ b/frontend/src/pages/GeneralSettings/CommunityHub/ImportItem/Steps/PullAndReview/HubItem/Unknown.jsx @@ -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> + ); +} diff --git a/frontend/src/pages/GeneralSettings/CommunityHub/ImportItem/Steps/PullAndReview/HubItem/index.js b/frontend/src/pages/GeneralSettings/CommunityHub/ImportItem/Steps/PullAndReview/HubItem/index.js new file mode 100644 index 0000000000000000000000000000000000000000..725ae45f52dc76a6f0be30c3f08dede148cb7de5 --- /dev/null +++ b/frontend/src/pages/GeneralSettings/CommunityHub/ImportItem/Steps/PullAndReview/HubItem/index.js @@ -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; diff --git a/frontend/src/pages/GeneralSettings/CommunityHub/ImportItem/Steps/PullAndReview/index.jsx b/frontend/src/pages/GeneralSettings/CommunityHub/ImportItem/Steps/PullAndReview/index.jsx new file mode 100644 index 0000000000000000000000000000000000000000..42aa47d0874995050efcd8fcb9187c7f6427f233 --- /dev/null +++ b/frontend/src/pages/GeneralSettings/CommunityHub/ImportItem/Steps/PullAndReview/index.jsx @@ -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> + ); +} diff --git a/frontend/src/pages/GeneralSettings/CommunityHub/ImportItem/Steps/index.jsx b/frontend/src/pages/GeneralSettings/CommunityHub/ImportItem/Steps/index.jsx new file mode 100644 index 0000000000000000000000000000000000000000..9a70e7674f7334e13aabd624c20abfd19608924f --- /dev/null +++ b/frontend/src/pages/GeneralSettings/CommunityHub/ImportItem/Steps/index.jsx @@ -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; diff --git a/frontend/src/pages/GeneralSettings/CommunityHub/ImportItem/index.jsx b/frontend/src/pages/GeneralSettings/CommunityHub/ImportItem/index.jsx new file mode 100644 index 0000000000000000000000000000000000000000..ee5e9e3d529a053ac6cd2bd94c86062dd233c144 --- /dev/null +++ b/frontend/src/pages/GeneralSettings/CommunityHub/ImportItem/index.jsx @@ -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> + ); +} diff --git a/frontend/src/pages/GeneralSettings/CommunityHub/Trending/HubItems/HubItemCard/agentSkill.jsx b/frontend/src/pages/GeneralSettings/CommunityHub/Trending/HubItems/HubItemCard/agentSkill.jsx new file mode 100644 index 0000000000000000000000000000000000000000..cac561be607a4b83dfa96c330a7ec5fc47b3f0f9 --- /dev/null +++ b/frontend/src/pages/GeneralSettings/CommunityHub/Trending/HubItems/HubItemCard/agentSkill.jsx @@ -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> + </> + ); +} diff --git a/frontend/src/pages/GeneralSettings/CommunityHub/Trending/HubItems/HubItemCard/generic.jsx b/frontend/src/pages/GeneralSettings/CommunityHub/Trending/HubItems/HubItemCard/generic.jsx new file mode 100644 index 0000000000000000000000000000000000000000..3b62dd077e541367870a84a09d08d4894348a5dc --- /dev/null +++ b/frontend/src/pages/GeneralSettings/CommunityHub/Trending/HubItems/HubItemCard/generic.jsx @@ -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" + /> + </> + ); +} diff --git a/frontend/src/pages/GeneralSettings/CommunityHub/Trending/HubItems/HubItemCard/index.jsx b/frontend/src/pages/GeneralSettings/CommunityHub/Trending/HubItems/HubItemCard/index.jsx new file mode 100644 index 0000000000000000000000000000000000000000..f2d53c0f77146bad1d365f4fa881a24a028ad724 --- /dev/null +++ b/frontend/src/pages/GeneralSettings/CommunityHub/Trending/HubItems/HubItemCard/index.jsx @@ -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} />; + } +} diff --git a/frontend/src/pages/GeneralSettings/CommunityHub/Trending/HubItems/HubItemCard/slashCommand.jsx b/frontend/src/pages/GeneralSettings/CommunityHub/Trending/HubItems/HubItemCard/slashCommand.jsx new file mode 100644 index 0000000000000000000000000000000000000000..7ed6781e74f943ef5b189d7c6ab63320eeed2714 --- /dev/null +++ b/frontend/src/pages/GeneralSettings/CommunityHub/Trending/HubItems/HubItemCard/slashCommand.jsx @@ -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> + </> + ); +} diff --git a/frontend/src/pages/GeneralSettings/CommunityHub/Trending/HubItems/HubItemCard/systemPrompt.jsx b/frontend/src/pages/GeneralSettings/CommunityHub/Trending/HubItems/HubItemCard/systemPrompt.jsx new file mode 100644 index 0000000000000000000000000000000000000000..3c929a309c2577dec651cedcd135075f998c2c6a --- /dev/null +++ b/frontend/src/pages/GeneralSettings/CommunityHub/Trending/HubItems/HubItemCard/systemPrompt.jsx @@ -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> + </> + ); +} diff --git a/frontend/src/pages/GeneralSettings/CommunityHub/Trending/HubItems/index.jsx b/frontend/src/pages/GeneralSettings/CommunityHub/Trending/HubItems/index.jsx new file mode 100644 index 0000000000000000000000000000000000000000..2c1ff58931812593f7e0676e94498848f3397f7e --- /dev/null +++ b/frontend/src/pages/GeneralSettings/CommunityHub/Trending/HubItems/index.jsx @@ -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> + ); +} diff --git a/frontend/src/pages/GeneralSettings/CommunityHub/Trending/index.jsx b/frontend/src/pages/GeneralSettings/CommunityHub/Trending/index.jsx new file mode 100644 index 0000000000000000000000000000000000000000..356029595ab46697720cff22c9d1e2c2cd9b7c63 --- /dev/null +++ b/frontend/src/pages/GeneralSettings/CommunityHub/Trending/index.jsx @@ -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> + ); +} diff --git a/frontend/src/pages/GeneralSettings/CommunityHub/utils.js b/frontend/src/pages/GeneralSettings/CommunityHub/utils.js new file mode 100644 index 0000000000000000000000000000000000000000..379da33d48da22fc404f21650a0a96a0277ca756 --- /dev/null +++ b/frontend/src/pages/GeneralSettings/CommunityHub/utils.js @@ -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"; + } +} diff --git a/frontend/src/utils/paths.js b/frontend/src/utils/paths.js index 554cc4599a38935d43826155909fee38d8af4c71..b4ab10573a2c13cda6b3a3245c22208d723da874 100644 --- a/frontend/src/utils/paths.js +++ b/frontend/src/utils/paths.js @@ -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`, diff --git a/server/endpoints/communityHub.js b/server/endpoints/communityHub.js new file mode 100644 index 0000000000000000000000000000000000000000..b8f0981ab528d1e72236de8b3b0c97767361b87c --- /dev/null +++ b/server/endpoints/communityHub.js @@ -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 }; diff --git a/server/endpoints/experimental/imported-agent-plugins.js b/server/endpoints/experimental/imported-agent-plugins.js index cdc0148cb2f16eae0fe86636b3ebb01e97a3ca16..cabe23d89e785aed02de04a2b1c96cf512dadb31 100644 --- a/server/endpoints/experimental/imported-agent-plugins.js +++ b/server/endpoints/experimental/imported-agent-plugins.js @@ -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 }; diff --git a/server/index.js b/server/index.js index 041d34c956279fee45c72add4eeecf538ccef01a..caff33e111ce4fafad70253b5566336e56732e77 100644 --- a/server/index.js +++ b/server/index.js @@ -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); diff --git a/server/models/communityHub.js b/server/models/communityHub.js new file mode 100644 index 0000000000000000000000000000000000000000..e68cdffaebd712daa64e66d39c1e39c470ae8496 --- /dev/null +++ b/server/models/communityHub.js @@ -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 }; diff --git a/server/models/slashCommandsPresets.js b/server/models/slashCommandsPresets.js index 4828c77d59d080a88d2c3d8b94291d4576e69f68..6aab3651667a4cb6738e5a8fdcb60202fcc9146f 100644 --- a/server/models/slashCommandsPresets.js +++ b/server/models/slashCommandsPresets.js @@ -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, diff --git a/server/models/systemSettings.js b/server/models/systemSettings.js index dd54b8e36217e4f919d6ee258e8d12bd0901f8b5..58011d868ae6206cfd01be440e57cbe6cd59aedc 100644 --- a/server/models/systemSettings.js +++ b/server/models/systemSettings.js @@ -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 = []) { diff --git a/server/package.json b/server/package.json index e090b431af83c1eac460ae6f4c65751ccaa38d93..a3716dfc8f967c654ba9aa38315cbf68f4801a5e 100644 --- a/server/package.json +++ b/server/package.json @@ -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" } -} \ No newline at end of file +} diff --git a/server/utils/agents/imported.js b/server/utils/agents/imported.js index 8a7da48025139818f7a84f2c9a07625b487f559b..f3ddf3c6ac40d3d8b5d641223ade087f55b77f8a 100644 --- a/server/utils/agents/imported.js +++ b/server/utils/agents/imported.js @@ -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; diff --git a/server/utils/helpers/updateENV.js b/server/utils/helpers/updateENV.js index 2c438462109dc8ab44e740de51b2093a1210afcd..0af4b839b30cdbb709e18a4b0fbd9fbbf532e9c3 100644 --- a/server/utils/helpers/updateENV.js +++ b/server/utils/helpers/updateENV.js @@ -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. diff --git a/server/utils/middleware/communityHubDownloadsEnabled.js b/server/utils/middleware/communityHubDownloadsEnabled.js new file mode 100644 index 0000000000000000000000000000000000000000..832176de4643e6e2ff4635e5fe36fe5ece665004 --- /dev/null +++ b/server/utils/middleware/communityHubDownloadsEnabled.js @@ -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, +}; diff --git a/server/yarn.lock b/server/yarn.lock index 44bab1f03ce577df819527c6c5eacb094bb3660d..7df1a54aa502fc261a80b3df9e74ff517e9acb22 100644 --- a/server/yarn.lock +++ b/server/yarn.lock @@ -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"