mirror of
https://github.com/Mintplex-Labs/anything-llm.git
synced 2025-05-02 09:03:12 +00:00
Community hub integration (#2555)
* wip hub connection page fe + backend * lint * implement backend for local hub items + placeholder endpoints to fetch hub app data * fix hebrew translations * revamp community integration flow * change sidebar * Auto import if id in URL param remove preview in card screen and instead go to import flow * get user's items + team items from hub + ui improvements to hub settings * lint * fix merge conflict * refresh hook for community items * add fallback for user items * Disable bundle items by default on all instances * remove translations (will complete later) * loading skeleton * Make community hub endpoints admin only show visibility on items combine import/apply for items to they are event logged for review * improve middleware and import flow * community hub ui updates * Adjust importing process * community hub to dev * Add webscraper preload into imported plugins * add runtime property to plugins * Fix button status on imported skill change show alert on skill change Update markdown type and theme on import of agent skill * update documentaion paths * remove unused import * linting * review loading state --------- Co-authored-by: Timothy Carambat <rambat1010@gmail.com>
This commit is contained in:
parent
8c9e9f2ec1
commit
05c530221b
40 changed files with 2449 additions and 7 deletions
.github/workflows
frontend/src
App.jsx
components
models
pages
Admin/Agents/Imported/ImportedSkillConfig
GeneralSettings/CommunityHub
utils
server
2
.github/workflows/dev-build.yaml
vendored
2
.github/workflows/dev-build.yaml
vendored
|
@ -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/*'
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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" />}
|
||||
|
|
|
@ -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;
|
||||
|
|
158
frontend/src/models/communityHub.js
Normal file
158
frontend/src/models/communityHub.js
Normal file
|
@ -0,0 +1,158 @@
|
|||
import { API_BASE } from "@/utils/constants";
|
||||
import { baseHeaders } from "@/utils/request";
|
||||
|
||||
const CommunityHub = {
|
||||
/**
|
||||
* Get an item from the community hub by its import ID.
|
||||
* @param {string} importId - The import ID of the item.
|
||||
* @returns {Promise<{error: string | null, item: object | null}>}
|
||||
*/
|
||||
getItemFromImportId: async (importId) => {
|
||||
return await fetch(`${API_BASE}/community-hub/item`, {
|
||||
method: "POST",
|
||||
headers: baseHeaders(),
|
||||
body: JSON.stringify({ importId }),
|
||||
})
|
||||
.then((res) => res.json())
|
||||
.catch((e) => {
|
||||
console.error(e);
|
||||
return {
|
||||
error: e.message,
|
||||
item: null,
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Apply an item to the AnythingLLM instance. Used for simple items like slash commands and system prompts.
|
||||
* @param {string} importId - The import ID of the item.
|
||||
* @param {object} options - Additional options for applying the item for whatever the item type requires.
|
||||
* @returns {Promise<{success: boolean, error: string | null}>}
|
||||
*/
|
||||
applyItem: async (importId, options = {}) => {
|
||||
return await fetch(`${API_BASE}/community-hub/apply`, {
|
||||
method: "POST",
|
||||
headers: baseHeaders(),
|
||||
body: JSON.stringify({ importId, options }),
|
||||
})
|
||||
.then((res) => res.json())
|
||||
.catch((e) => {
|
||||
console.error(e);
|
||||
return {
|
||||
success: false,
|
||||
error: e.message,
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Import a bundle item from the community hub.
|
||||
* @param {string} importId - The import ID of the item.
|
||||
* @returns {Promise<{error: string | null, item: object | null}>}
|
||||
*/
|
||||
importBundleItem: async (importId) => {
|
||||
return await fetch(`${API_BASE}/community-hub/import`, {
|
||||
method: "POST",
|
||||
headers: baseHeaders(),
|
||||
body: JSON.stringify({ importId }),
|
||||
})
|
||||
.then(async (res) => {
|
||||
const response = await res.json();
|
||||
if (!res.ok) throw new Error(response?.error ?? res.statusText);
|
||||
return response;
|
||||
})
|
||||
.catch((e) => {
|
||||
return {
|
||||
error: e.message,
|
||||
item: null,
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Update the hub settings (API key, etc.)
|
||||
* @param {Object} data - The data to update.
|
||||
* @returns {Promise<{success: boolean, error: string | null}>}
|
||||
*/
|
||||
updateSettings: async (data) => {
|
||||
return await fetch(`${API_BASE}/community-hub/settings`, {
|
||||
method: "POST",
|
||||
headers: baseHeaders(),
|
||||
body: JSON.stringify(data),
|
||||
})
|
||||
.then(async (res) => {
|
||||
const response = await res.json();
|
||||
if (!res.ok)
|
||||
throw new Error(response.error || "Failed to update settings");
|
||||
return { success: true, error: null };
|
||||
})
|
||||
.catch((e) => ({
|
||||
success: false,
|
||||
error: e.message,
|
||||
}));
|
||||
},
|
||||
|
||||
/**
|
||||
* Get the hub settings (API key, etc.)
|
||||
* @returns {Promise<{connectionKey: string | null, error: string | null}>}
|
||||
*/
|
||||
getSettings: async () => {
|
||||
return await fetch(`${API_BASE}/community-hub/settings`, {
|
||||
method: "GET",
|
||||
headers: baseHeaders(),
|
||||
})
|
||||
.then(async (res) => {
|
||||
const response = await res.json();
|
||||
if (!res.ok)
|
||||
throw new Error(response.error || "Failed to fetch settings");
|
||||
return { connectionKey: response.connectionKey, error: null };
|
||||
})
|
||||
.catch((e) => ({
|
||||
connectionKey: null,
|
||||
error: e.message,
|
||||
}));
|
||||
},
|
||||
|
||||
/**
|
||||
* Fetch the explore items from the community hub that are publicly available.
|
||||
* @returns {Promise<{agentSkills: {items: [], hasMore: boolean, totalCount: number}, systemPrompts: {items: [], hasMore: boolean, totalCount: number}, slashCommands: {items: [], hasMore: boolean, totalCount: number}}>}
|
||||
*/
|
||||
fetchExploreItems: async () => {
|
||||
return await fetch(`${API_BASE}/community-hub/explore`, {
|
||||
method: "GET",
|
||||
headers: baseHeaders(),
|
||||
})
|
||||
.then((res) => res.json())
|
||||
.catch((e) => {
|
||||
console.error(e);
|
||||
return {
|
||||
success: false,
|
||||
error: e.message,
|
||||
result: null,
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Fetch the user items from the community hub.
|
||||
* @returns {Promise<{success: boolean, error: string | null, createdByMe: object, teamItems: object[]}>}
|
||||
*/
|
||||
fetchUserItems: async () => {
|
||||
return await fetch(`${API_BASE}/community-hub/items`, {
|
||||
method: "GET",
|
||||
headers: baseHeaders(),
|
||||
})
|
||||
.then((res) => res.json())
|
||||
.catch((e) => {
|
||||
console.error(e);
|
||||
return {
|
||||
success: false,
|
||||
error: e.message,
|
||||
createdByMe: {},
|
||||
teamItems: [],
|
||||
};
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
export default CommunityHub;
|
|
@ -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;
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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 };
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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;
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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;
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -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"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -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} />;
|
||||
}
|
||||
}
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
37
frontend/src/pages/GeneralSettings/CommunityHub/utils.js
Normal file
37
frontend/src/pages/GeneralSettings/CommunityHub/utils.js
Normal file
|
@ -0,0 +1,37 @@
|
|||
/**
|
||||
* Convert a type to a readable string for the community hub.
|
||||
* @param {("agentSkills" | "agentSkill" | "systemPrompts" | "systemPrompt" | "slashCommands" | "slashCommand")} type
|
||||
* @returns {string}
|
||||
*/
|
||||
export function readableType(type) {
|
||||
switch (type) {
|
||||
case "agentSkills":
|
||||
case "agentSkill":
|
||||
return "Agent Skills";
|
||||
case "systemPrompt":
|
||||
case "systemPrompts":
|
||||
return "System Prompts";
|
||||
case "slashCommand":
|
||||
case "slashCommands":
|
||||
return "Slash Commands";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a type to a path for the community hub.
|
||||
* @param {("agentSkill" | "agentSkills" | "systemPrompt" | "systemPrompts" | "slashCommand" | "slashCommands")} type
|
||||
* @returns {string}
|
||||
*/
|
||||
export function typeToPath(type) {
|
||||
switch (type) {
|
||||
case "agentSkill":
|
||||
case "agentSkills":
|
||||
return "agent-skills";
|
||||
case "systemPrompt":
|
||||
case "systemPrompts":
|
||||
return "system-prompts";
|
||||
case "slashCommand":
|
||||
case "slashCommands":
|
||||
return "slash-commands";
|
||||
}
|
||||
}
|
|
@ -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`,
|
||||
|
|
186
server/endpoints/communityHub.js
Normal file
186
server/endpoints/communityHub.js
Normal file
|
@ -0,0 +1,186 @@
|
|||
const { SystemSettings } = require("../models/systemSettings");
|
||||
const { validatedRequest } = require("../utils/middleware/validatedRequest");
|
||||
const { reqBody } = require("../utils/http");
|
||||
const { CommunityHub } = require("../models/communityHub");
|
||||
const {
|
||||
communityHubDownloadsEnabled,
|
||||
communityHubItem,
|
||||
} = require("../utils/middleware/communityHubDownloadsEnabled");
|
||||
const { EventLogs } = require("../models/eventLogs");
|
||||
const { Telemetry } = require("../models/telemetry");
|
||||
const {
|
||||
flexUserRoleValid,
|
||||
ROLES,
|
||||
} = require("../utils/middleware/multiUserProtected");
|
||||
|
||||
function communityHubEndpoints(app) {
|
||||
if (!app) return;
|
||||
|
||||
app.get(
|
||||
"/community-hub/settings",
|
||||
[validatedRequest, flexUserRoleValid([ROLES.admin])],
|
||||
async (_, response) => {
|
||||
try {
|
||||
const { connectionKey } = await SystemSettings.hubSettings();
|
||||
response.status(200).json({ success: true, connectionKey });
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
response.status(500).json({ success: false, error: error.message });
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
app.post(
|
||||
"/community-hub/settings",
|
||||
[validatedRequest, flexUserRoleValid([ROLES.admin])],
|
||||
async (request, response) => {
|
||||
try {
|
||||
const data = reqBody(request);
|
||||
const result = await SystemSettings.updateSettings(data);
|
||||
if (result.error) throw new Error(result.error);
|
||||
response.status(200).json({ success: true, error: null });
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
response.status(500).json({ success: false, error: error.message });
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
app.get(
|
||||
"/community-hub/explore",
|
||||
[validatedRequest, flexUserRoleValid([ROLES.admin])],
|
||||
async (_, response) => {
|
||||
try {
|
||||
const exploreItems = await CommunityHub.fetchExploreItems();
|
||||
response.status(200).json({ success: true, result: exploreItems });
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
response.status(500).json({
|
||||
success: false,
|
||||
result: null,
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
app.post(
|
||||
"/community-hub/item",
|
||||
[validatedRequest, flexUserRoleValid([ROLES.admin]), communityHubItem],
|
||||
async (_request, response) => {
|
||||
try {
|
||||
response.status(200).json({
|
||||
success: true,
|
||||
item: response.locals.bundleItem,
|
||||
error: null,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
response.status(500).json({
|
||||
success: false,
|
||||
item: null,
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* Apply an item to the AnythingLLM instance. Used for simple items like slash commands and system prompts.
|
||||
*/
|
||||
app.post(
|
||||
"/community-hub/apply",
|
||||
[validatedRequest, flexUserRoleValid([ROLES.admin]), communityHubItem],
|
||||
async (request, response) => {
|
||||
try {
|
||||
const { options = {} } = reqBody(request);
|
||||
const item = response.locals.bundleItem;
|
||||
const { error: applyError } = await CommunityHub.applyItem(item, {
|
||||
...options,
|
||||
currentUser: response.locals?.user,
|
||||
});
|
||||
if (applyError) throw new Error(applyError);
|
||||
|
||||
await Telemetry.sendTelemetry("community_hub_import", {
|
||||
itemType: response.locals.bundleItem.itemType,
|
||||
visibility: response.locals.bundleItem.visibility,
|
||||
});
|
||||
await EventLogs.logEvent(
|
||||
"community_hub_import",
|
||||
{
|
||||
itemId: response.locals.bundleItem.id,
|
||||
itemType: response.locals.bundleItem.itemType,
|
||||
},
|
||||
response.locals?.user?.id
|
||||
);
|
||||
|
||||
response.status(200).json({ success: true, error: null });
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
response.status(500).json({ success: false, error: error.message });
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* Import a bundle item to the AnythingLLM instance by downloading the zip file and importing it.
|
||||
* or whatever the item type requires. This is not used if the item is a simple text responses like
|
||||
* slash commands or system prompts.
|
||||
*/
|
||||
app.post(
|
||||
"/community-hub/import",
|
||||
[
|
||||
validatedRequest,
|
||||
flexUserRoleValid([ROLES.admin]),
|
||||
communityHubItem,
|
||||
communityHubDownloadsEnabled,
|
||||
],
|
||||
async (_, response) => {
|
||||
try {
|
||||
const { error: importError } = await CommunityHub.importBundleItem({
|
||||
url: response.locals.bundleUrl,
|
||||
item: response.locals.bundleItem,
|
||||
});
|
||||
if (importError) throw new Error(importError);
|
||||
|
||||
await Telemetry.sendTelemetry("community_hub_import", {
|
||||
itemType: response.locals.bundleItem.itemType,
|
||||
visibility: response.locals.bundleItem.visibility,
|
||||
});
|
||||
await EventLogs.logEvent(
|
||||
"community_hub_import",
|
||||
{
|
||||
itemId: response.locals.bundleItem.id,
|
||||
itemType: response.locals.bundleItem.itemType,
|
||||
},
|
||||
response.locals?.user?.id
|
||||
);
|
||||
|
||||
response.status(200).json({ success: true, error: null });
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
response.status(500).json({
|
||||
success: false,
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
app.get(
|
||||
"/community-hub/items",
|
||||
[validatedRequest, flexUserRoleValid([ROLES.admin])],
|
||||
async (_, response) => {
|
||||
try {
|
||||
const { connectionKey } = await SystemSettings.hubSettings();
|
||||
const items = await CommunityHub.fetchUserItems(connectionKey);
|
||||
response.status(200).json({ success: true, ...items });
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
response.status(500).json({ success: false, error: error.message });
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
module.exports = { communityHubEndpoints };
|
|
@ -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 };
|
||||
|
|
|
@ -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);
|
||||
|
|
177
server/models/communityHub.js
Normal file
177
server/models/communityHub.js
Normal file
|
@ -0,0 +1,177 @@
|
|||
const ImportedPlugin = require("../utils/agents/imported");
|
||||
|
||||
/**
|
||||
* An interface to the AnythingLLM Community Hub external API.
|
||||
*/
|
||||
const CommunityHub = {
|
||||
importPrefix: "allm-community-id",
|
||||
apiBase:
|
||||
process.env.NODE_ENV === "development"
|
||||
? "http://127.0.0.1:5001/anythingllm-hub/us-central1/external/v1"
|
||||
: "https://hub.external.anythingllm.com/v1",
|
||||
|
||||
/**
|
||||
* Validate an import ID and return the entity type and ID.
|
||||
* @param {string} importId - The import ID to validate.
|
||||
* @returns {{entityType: string | null, entityId: string | null}}
|
||||
*/
|
||||
validateImportId: function (importId) {
|
||||
if (
|
||||
!importId ||
|
||||
!importId.startsWith(this.importPrefix) ||
|
||||
importId.split(":").length !== 3
|
||||
)
|
||||
return { entityType: null, entityId: null };
|
||||
const [_, entityType, entityId] = importId.split(":");
|
||||
if (!entityType || !entityId) return { entityType: null, entityId: null };
|
||||
return {
|
||||
entityType: String(entityType).trim(),
|
||||
entityId: String(entityId).trim(),
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* Fetch the explore items from the community hub that are publicly available.
|
||||
* @returns {Promise<{agentSkills: {items: [], hasMore: boolean, totalCount: number}, systemPrompts: {items: [], hasMore: boolean, totalCount: number}, slashCommands: {items: [], hasMore: boolean, totalCount: number}}>}
|
||||
*/
|
||||
fetchExploreItems: async function () {
|
||||
return await fetch(`${this.apiBase}/explore`, {
|
||||
method: "GET",
|
||||
})
|
||||
.then((response) => response.json())
|
||||
.catch((error) => {
|
||||
console.error("Error fetching explore items:", error);
|
||||
return {
|
||||
agentSkills: {
|
||||
items: [],
|
||||
hasMore: false,
|
||||
totalCount: 0,
|
||||
},
|
||||
systemPrompts: {
|
||||
items: [],
|
||||
hasMore: false,
|
||||
totalCount: 0,
|
||||
},
|
||||
slashCommands: {
|
||||
items: [],
|
||||
hasMore: false,
|
||||
totalCount: 0,
|
||||
},
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Fetch a bundle item from the community hub.
|
||||
* Bundle items are entities that require a downloadURL to be fetched from the community hub.
|
||||
* so we can unzip and import them to the AnythingLLM instance.
|
||||
* @param {string} importId - The import ID of the item.
|
||||
* @returns {Promise<{url: string | null, item: object | null, error: string | null}>}
|
||||
*/
|
||||
getBundleItem: async function (importId) {
|
||||
const { entityType, entityId } = this.validateImportId(importId);
|
||||
if (!entityType || !entityId)
|
||||
return { item: null, error: "Invalid import ID" };
|
||||
|
||||
const { SystemSettings } = require("./systemSettings");
|
||||
const { connectionKey } = await SystemSettings.hubSettings();
|
||||
const { url, item, error } = await fetch(
|
||||
`${this.apiBase}/${entityType}/${entityId}/pull`,
|
||||
{
|
||||
method: "GET",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...(connectionKey
|
||||
? { Authorization: `Bearer ${connectionKey}` }
|
||||
: {}),
|
||||
},
|
||||
}
|
||||
)
|
||||
.then((response) => response.json())
|
||||
.catch((error) => {
|
||||
console.error(
|
||||
`Error fetching bundle item for import ID ${importId}:`,
|
||||
error
|
||||
);
|
||||
return { url: null, item: null, error: error.message };
|
||||
});
|
||||
return { url, item, error };
|
||||
},
|
||||
|
||||
/**
|
||||
* Apply an item to the AnythingLLM instance. Used for simple items like slash commands and system prompts.
|
||||
* @param {object} item - The item to apply.
|
||||
* @param {object} options - Additional options for applying the item.
|
||||
* @param {object|null} options.currentUser - The current user object.
|
||||
* @returns {Promise<{success: boolean, error: string | null}>}
|
||||
*/
|
||||
applyItem: async function (item, options = {}) {
|
||||
if (!item) return { success: false, error: "Item is required" };
|
||||
|
||||
if (item.itemType === "system-prompt") {
|
||||
if (!options?.workspaceSlug)
|
||||
return { success: false, error: "Workspace slug is required" };
|
||||
|
||||
const { Workspace } = require("./workspace");
|
||||
const workspace = await Workspace.get({
|
||||
slug: String(options.workspaceSlug),
|
||||
});
|
||||
if (!workspace) return { success: false, error: "Workspace not found" };
|
||||
await Workspace.update(workspace.id, { openAiPrompt: item.prompt });
|
||||
return { success: true, error: null };
|
||||
}
|
||||
|
||||
if (item.itemType === "slash-command") {
|
||||
const { SlashCommandPresets } = require("./slashCommandsPresets");
|
||||
await SlashCommandPresets.create(options?.currentUser?.id, {
|
||||
command: SlashCommandPresets.formatCommand(String(item.command)),
|
||||
prompt: String(item.prompt),
|
||||
description: String(item.description),
|
||||
});
|
||||
return { success: true, error: null };
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: "Unsupported item type. Nothing to apply.",
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* Import a bundle item to the AnythingLLM instance by downloading the zip file and importing it.
|
||||
* or whatever the item type requires.
|
||||
* @param {{url: string, item: object}} params
|
||||
* @returns {Promise<{success: boolean, error: string | null}>}
|
||||
*/
|
||||
importBundleItem: async function ({ url, item }) {
|
||||
if (item.itemType === "agent-skill") {
|
||||
const { success, error } =
|
||||
await ImportedPlugin.importCommunityItemFromUrl(url, item);
|
||||
return { success, error };
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: "Unsupported item type. Nothing to import.",
|
||||
};
|
||||
},
|
||||
|
||||
fetchUserItems: async function (connectionKey) {
|
||||
if (!connectionKey) return { createdByMe: {}, teamItems: [] };
|
||||
|
||||
return await fetch(`${this.apiBase}/items`, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${connectionKey}`,
|
||||
},
|
||||
})
|
||||
.then((response) => response.json())
|
||||
.catch((error) => {
|
||||
console.error("Error fetching user items:", error);
|
||||
return { createdByMe: {}, teamItems: [] };
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
module.exports = { CommunityHub };
|
|
@ -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,
|
||||
|
|
|
@ -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 = []) {
|
||||
|
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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.
|
||||
|
|
77
server/utils/middleware/communityHubDownloadsEnabled.js
Normal file
77
server/utils/middleware/communityHubDownloadsEnabled.js
Normal file
|
@ -0,0 +1,77 @@
|
|||
const { CommunityHub } = require("../../models/communityHub");
|
||||
const { reqBody } = require("../http");
|
||||
|
||||
/**
|
||||
* ### Must be called after `communityHubItem`
|
||||
* Checks if community hub bundle downloads are enabled. The reason this functionality is disabled
|
||||
* by default is that since AgentSkills, Workspaces, and DataConnectors are all imported from the
|
||||
* community hub via unzipping a bundle - it would be possible for a malicious user to craft and
|
||||
* download a malicious bundle and import it into their own hosted instance. To avoid this, this
|
||||
* functionality is disabled by default and must be enabled manually by the system administrator.
|
||||
*
|
||||
* On hosted systems, this would not be an issue since the user cannot modify this setting, but those
|
||||
* who self-host can still unlock this feature manually by setting the environment variable
|
||||
* which would require someone who likely has the capacity to understand the risks and the
|
||||
* implications of importing unverified items that can run code on their system, container, or instance.
|
||||
* @see {@link https://docs.anythingllm.com/docs/community-hub/import}
|
||||
* @param {import("express").Request} request
|
||||
* @param {import("express").Response} response
|
||||
* @param {import("express").NextFunction} next
|
||||
* @returns {void}
|
||||
*/
|
||||
function communityHubDownloadsEnabled(request, response, next) {
|
||||
if (!("COMMUNITY_HUB_BUNDLE_DOWNLOADS_ENABLED" in process.env)) {
|
||||
return response.status(422).json({
|
||||
error:
|
||||
"Community Hub bundle downloads are not enabled. The system administrator must enable this feature manually to allow this instance to download these types of items. See https://docs.anythingllm.com/configuration#anythingllm-hub-agent-skills",
|
||||
});
|
||||
}
|
||||
|
||||
// If the admin specifically did not set the system to `allow_all` then downloads are limited to verified items or private items only.
|
||||
// This is to prevent users from downloading unverified items and importing them into their own instance without understanding the risks.
|
||||
const item = response.locals.bundleItem;
|
||||
if (
|
||||
!item.verified &&
|
||||
item.visibility !== "private" &&
|
||||
process.env.COMMUNITY_HUB_BUNDLE_DOWNLOADS_ENABLED !== "allow_all"
|
||||
) {
|
||||
return response.status(422).json({
|
||||
error:
|
||||
"Community hub bundle downloads are limited to verified public items or private team items only. Please contact the system administrator to review or modify this setting. See https://docs.anythingllm.com/configuration#anythingllm-hub-agent-skills",
|
||||
});
|
||||
}
|
||||
next();
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the bundle item from the community hub.
|
||||
* Sets `response.locals.bundleItem` and `response.locals.bundleUrl`.
|
||||
*/
|
||||
async function communityHubItem(request, response, next) {
|
||||
const { importId } = reqBody(request);
|
||||
if (!importId)
|
||||
return response.status(500).json({
|
||||
success: false,
|
||||
error: "Import ID is required",
|
||||
});
|
||||
|
||||
const {
|
||||
url,
|
||||
item,
|
||||
error: fetchError,
|
||||
} = await CommunityHub.getBundleItem(importId);
|
||||
if (fetchError)
|
||||
return response.status(500).json({
|
||||
success: false,
|
||||
error: fetchError,
|
||||
});
|
||||
|
||||
response.locals.bundleItem = item;
|
||||
response.locals.bundleUrl = url;
|
||||
next();
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
communityHubItem,
|
||||
communityHubDownloadsEnabled,
|
||||
};
|
|
@ -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"
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue