Admin embed config page
This commit is contained in:
parent
efc54be4ae
commit
b219c5df0e
|
@ -1,6 +1,9 @@
|
||||||
{
|
{
|
||||||
"cSpell.words": [
|
"cSpell.words": [
|
||||||
|
"anythingllm",
|
||||||
"Dockerized",
|
"Dockerized",
|
||||||
|
"Embeddable",
|
||||||
|
"hljs",
|
||||||
"Langchain",
|
"Langchain",
|
||||||
"Milvus",
|
"Milvus",
|
||||||
"Ollama",
|
"Ollama",
|
||||||
|
|
|
@ -3,9 +3,9 @@
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
<h1>This is an example testing page for embedded AnythingLLM.</h1>
|
<h1>This is an example testing page for embedded AnythingLLM.</h1>
|
||||||
<script data-embed-id="example-uuid" data-base-api-url='http://localhost:3001/api/embed' data-open-on-load="on"
|
<!-- <script data-embed-id="example-uuid" data-base-api-url='http://localhost:3001/api/embed' data-open-on-load="on"
|
||||||
src="/dist/embedded-anything-llm.umd.js">
|
src="/dist/anythingllm-chat-widget.js">
|
||||||
</script>
|
</script> -->
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
</html>
|
</html>
|
|
@ -5,8 +5,9 @@
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "nodemon -e js,jsx,css --watch src --exec \"yarn run dev:preview\"",
|
"dev": "nodemon -e js,jsx,css --watch src --exec \"yarn run dev:preview\"",
|
||||||
"dev:preview": "yarn run dev:build && yarn serve . -p 3080 --no-clipboard",
|
"dev:preview": "yarn run dev:build && yarn serve . -p 3080 --no-clipboard",
|
||||||
"dev:build": "vite build && cat src/static/tailwind@3.4.1.js >> dist/embedded-anything-llm.umd.js",
|
"dev:build": "vite build && cat src/static/tailwind@3.4.1.js >> dist/anythingllm-chat-widget.js",
|
||||||
"build": "vite build && cat src/static/tailwind@3.4.1.js >> dist/embedded-anything-llm.umd.js && npx terser --compress -o dist/embedded-anything-llm.umd.min.js -- dist/embedded-anything-llm.umd.js",
|
"build": "vite build && cat src/static/tailwind@3.4.1.js >> dist/anythingllm-chat-widget.js && npx terser --compress -o dist/anythingllm-chat-widget.min.js -- dist/anythingllm-chat-widget.js",
|
||||||
|
"build:publish": "yarn build && mkdir -p ../frontend/public/embed && cp -r dist/anythingllm-chat-widget.min.js ../frontend/public/embed/anythingllm-chat-widget.min.js",
|
||||||
"lint": "yarn prettier --write ./src"
|
"lint": "yarn prettier --write ./src"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|
|
@ -33,7 +33,7 @@ export default defineConfig({
|
||||||
entry: "src/main.jsx",
|
entry: "src/main.jsx",
|
||||||
name: "EmbeddedAnythingLLM",
|
name: "EmbeddedAnythingLLM",
|
||||||
formats: ["umd"],
|
formats: ["umd"],
|
||||||
fileName: (format) => `embedded-anything-llm.${format}.js`
|
fileName: (_format) => `anythingllm-chat-widget.js`
|
||||||
},
|
},
|
||||||
rollupOptions: {
|
rollupOptions: {
|
||||||
external: [
|
external: [
|
||||||
|
|
File diff suppressed because one or more lines are too long
|
@ -41,6 +41,10 @@ const DataConnectors = lazy(
|
||||||
const DataConnectorSetup = lazy(
|
const DataConnectorSetup = lazy(
|
||||||
() => import("@/pages/GeneralSettings/DataConnectors/Connectors")
|
() => import("@/pages/GeneralSettings/DataConnectors/Connectors")
|
||||||
);
|
);
|
||||||
|
const EmbedConfigSetup = lazy(
|
||||||
|
() => import("@/pages/GeneralSettings/EmbedConfigs")
|
||||||
|
);
|
||||||
|
const EmbedChats = lazy(() => import("@/pages/Admin/Users"));
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
return (
|
return (
|
||||||
|
@ -70,6 +74,14 @@ export default function App() {
|
||||||
path="/settings/vector-database"
|
path="/settings/vector-database"
|
||||||
element={<AdminRoute Component={GeneralVectorDatabase} />}
|
element={<AdminRoute Component={GeneralVectorDatabase} />}
|
||||||
/>
|
/>
|
||||||
|
<Route
|
||||||
|
path="/settings/embed-config"
|
||||||
|
element={<AdminRoute Component={EmbedConfigSetup} />}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/settings/embed-chats"
|
||||||
|
element={<AdminRoute Component={EmbedChats} />}
|
||||||
|
/>
|
||||||
{/* Manager */}
|
{/* Manager */}
|
||||||
<Route
|
<Route
|
||||||
path="/settings/security"
|
path="/settings/security"
|
||||||
|
|
|
@ -19,6 +19,8 @@ import {
|
||||||
List,
|
List,
|
||||||
FileCode,
|
FileCode,
|
||||||
Plugs,
|
Plugs,
|
||||||
|
CodeBlock,
|
||||||
|
Barcode,
|
||||||
} from "@phosphor-icons/react";
|
} from "@phosphor-icons/react";
|
||||||
import useUser from "@/hooks/useUser";
|
import useUser from "@/hooks/useUser";
|
||||||
import { USER_BACKGROUND_COLOR } from "@/utils/constants";
|
import { USER_BACKGROUND_COLOR } from "@/utils/constants";
|
||||||
|
@ -146,6 +148,27 @@ export default function SettingsSidebar() {
|
||||||
flex={true}
|
flex={true}
|
||||||
allowedRole={["admin", "manager"]}
|
allowedRole={["admin", "manager"]}
|
||||||
/>
|
/>
|
||||||
|
<Option
|
||||||
|
href={paths.settings.embedSetup()}
|
||||||
|
childLinks={[paths.settings.embedChats()]}
|
||||||
|
btnText="Embedded Chat"
|
||||||
|
icon={<CodeBlock className="h-5 w-5 flex-shrink-0" />}
|
||||||
|
user={user}
|
||||||
|
flex={true}
|
||||||
|
allowedRole={["admin"]}
|
||||||
|
subOptions={
|
||||||
|
<>
|
||||||
|
<Option
|
||||||
|
href={paths.settings.embedChats()}
|
||||||
|
btnText="Embedded Chat History"
|
||||||
|
icon={<Barcode className="h-5 w-5 flex-shrink-0" />}
|
||||||
|
user={user}
|
||||||
|
flex={true}
|
||||||
|
allowedRole={["admin"]}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
/>
|
||||||
<Option
|
<Option
|
||||||
href={paths.settings.security()}
|
href={paths.settings.security()}
|
||||||
btnText="Security"
|
btnText="Security"
|
||||||
|
@ -365,6 +388,27 @@ export function SidebarMobileHeader() {
|
||||||
flex={true}
|
flex={true}
|
||||||
allowedRole={["admin", "manager"]}
|
allowedRole={["admin", "manager"]}
|
||||||
/>
|
/>
|
||||||
|
<Option
|
||||||
|
href={paths.settings.embedSetup()}
|
||||||
|
childLinks={[paths.settings.embedChats()]}
|
||||||
|
btnText="Embedded Chat"
|
||||||
|
icon={<CodeBlock className="h-5 w-5 flex-shrink-0" />}
|
||||||
|
user={user}
|
||||||
|
flex={true}
|
||||||
|
allowedRole={["admin"]}
|
||||||
|
subOptions={
|
||||||
|
<>
|
||||||
|
<Option
|
||||||
|
href={paths.settings.embedChats()}
|
||||||
|
btnText="Embedded Chat History"
|
||||||
|
icon={<Barcode className="h-5 w-5 flex-shrink-0" />}
|
||||||
|
user={user}
|
||||||
|
flex={true}
|
||||||
|
allowedRole={["admin"]}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
/>
|
||||||
<Option
|
<Option
|
||||||
href={paths.settings.security()}
|
href={paths.settings.security()}
|
||||||
btnText="Security"
|
btnText="Security"
|
||||||
|
@ -418,10 +462,13 @@ const Option = ({
|
||||||
btnText,
|
btnText,
|
||||||
icon,
|
icon,
|
||||||
href,
|
href,
|
||||||
|
childLinks = [],
|
||||||
flex = false,
|
flex = false,
|
||||||
user = null,
|
user = null,
|
||||||
allowedRole = [],
|
allowedRole = [],
|
||||||
|
subOptions = null,
|
||||||
}) => {
|
}) => {
|
||||||
|
const hasActiveChild = childLinks.includes(window.location.pathname);
|
||||||
const isActive = window.location.pathname === href;
|
const isActive = window.location.pathname === href;
|
||||||
|
|
||||||
// Option only for multi-user
|
// Option only for multi-user
|
||||||
|
@ -430,10 +477,11 @@ const Option = ({
|
||||||
// Option is dual-mode, but user exists, we need to check permissions
|
// Option is dual-mode, but user exists, we need to check permissions
|
||||||
if (flex && !!user && !allowedRole.includes(user?.role)) return null;
|
if (flex && !!user && !allowedRole.includes(user?.role)) return null;
|
||||||
return (
|
return (
|
||||||
<div className="flex gap-x-2 items-center justify-between text-white">
|
<>
|
||||||
<a
|
<div className="flex gap-x-2 items-center justify-between text-white">
|
||||||
href={href}
|
<a
|
||||||
className={`
|
href={href}
|
||||||
|
className={`
|
||||||
transition-all duration-[200ms]
|
transition-all duration-[200ms]
|
||||||
flex flex-grow w-[75%] h-[36px] gap-x-2 py-[5px] px-4 rounded justify-start items-center border
|
flex flex-grow w-[75%] h-[36px] gap-x-2 py-[5px] px-4 rounded justify-start items-center border
|
||||||
${
|
${
|
||||||
|
@ -442,12 +490,22 @@ const Option = ({
|
||||||
: "hover:bg-menu-item-selected-gradient hover:border-slate-100 hover:border-opacity-50 border-transparent"
|
: "hover:bg-menu-item-selected-gradient hover:border-slate-100 hover:border-opacity-50 border-transparent"
|
||||||
}
|
}
|
||||||
`}
|
`}
|
||||||
>
|
>
|
||||||
{React.cloneElement(icon, { weight: isActive ? "fill" : "regular" })}
|
{React.cloneElement(icon, { weight: isActive ? "fill" : "regular" })}
|
||||||
<p className="text-sm leading-loose text-opacity-60 whitespace-nowrap overflow-hidden ">
|
<p className="text-sm leading-loose text-opacity-60 whitespace-nowrap overflow-hidden ">
|
||||||
{btnText}
|
{btnText}
|
||||||
</p>
|
</p>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
{!!subOptions && (isActive || hasActiveChild) && (
|
||||||
|
<div
|
||||||
|
className={`ml-4 ${
|
||||||
|
hasActiveChild ? "" : "border-l-2 border-slate-400"
|
||||||
|
} rounded-r-lg`}
|
||||||
|
>
|
||||||
|
{subOptions}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
@ -0,0 +1,57 @@
|
||||||
|
import { API_BASE } from "@/utils/constants";
|
||||||
|
import { baseHeaders } from "@/utils/request";
|
||||||
|
|
||||||
|
const Embed = {
|
||||||
|
embeds: async () => {
|
||||||
|
return await fetch(`${API_BASE}/embeds`, {
|
||||||
|
method: "GET",
|
||||||
|
headers: baseHeaders(),
|
||||||
|
})
|
||||||
|
.then((res) => res.json())
|
||||||
|
.then((res) => res?.embeds || [])
|
||||||
|
.catch((e) => {
|
||||||
|
console.error(e);
|
||||||
|
return [];
|
||||||
|
});
|
||||||
|
},
|
||||||
|
newEmbed: async (data) => {
|
||||||
|
return await fetch(`${API_BASE}/embeds/new`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: baseHeaders(),
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
})
|
||||||
|
.then((res) => res.json())
|
||||||
|
.catch((e) => {
|
||||||
|
console.error(e);
|
||||||
|
return { embed: null, error: e.message };
|
||||||
|
});
|
||||||
|
},
|
||||||
|
updateEmbed: async (embedId, data) => {
|
||||||
|
return await fetch(`${API_BASE}/embed/update/${embedId}`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: baseHeaders(),
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
})
|
||||||
|
.then((res) => res.json())
|
||||||
|
.catch((e) => {
|
||||||
|
console.error(e);
|
||||||
|
return { success: false, error: e.message };
|
||||||
|
});
|
||||||
|
},
|
||||||
|
deleteEmbed: async (embedId) => {
|
||||||
|
return await fetch(`${API_BASE}/embed/${embedId}`, {
|
||||||
|
method: "DELETE",
|
||||||
|
headers: baseHeaders(),
|
||||||
|
})
|
||||||
|
.then((res) => {
|
||||||
|
if (res.ok) return { success: true, error: null };
|
||||||
|
throw new Error(res.statusText);
|
||||||
|
})
|
||||||
|
.catch((e) => {
|
||||||
|
console.error(e);
|
||||||
|
return { success: true, error: e.message };
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Embed;
|
|
@ -0,0 +1,120 @@
|
||||||
|
import React, { useState } from "react";
|
||||||
|
import { CheckCircle, CopySimple, X } from "@phosphor-icons/react";
|
||||||
|
import showToast from "@/utils/toast";
|
||||||
|
import hljs from "highlight.js";
|
||||||
|
import { encode as HTMLEncode } from "he";
|
||||||
|
|
||||||
|
// import hljsHTML from 'highlight.js/lib/languages/vbscript-html';
|
||||||
|
import "highlight.js/styles/github-dark-dimmed.min.css";
|
||||||
|
// hljs.registerLanguage('html', hljsHTML)
|
||||||
|
|
||||||
|
export default function CodeSnippetModal({ embed, closeModal }) {
|
||||||
|
return (
|
||||||
|
<div className="relative max-w-2xl max-h-full">
|
||||||
|
<div className="relative bg-main-gradient rounded-lg shadow">
|
||||||
|
<div className="flex items-start justify-between p-4 border-b rounded-t border-gray-500/50">
|
||||||
|
<h3 className="text-xl font-semibold text-white">
|
||||||
|
Copy your embed code
|
||||||
|
</h3>
|
||||||
|
<button
|
||||||
|
onClick={closeModal}
|
||||||
|
type="button"
|
||||||
|
className="transition-all duration-300 text-gray-400 bg-transparent hover:border-white/60 rounded-lg text-sm p-1.5 ml-auto inline-flex items-center bg-sidebar-button hover:bg-menu-item-selected-gradient hover:border-slate-100 hover:border-opacity-50 border-transparent border"
|
||||||
|
data-modal-hide="staticModal"
|
||||||
|
>
|
||||||
|
<X className="text-gray-300 text-lg" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="p-6 space-y-6 flex h-auto max-h-[80vh] w-full overflow-y-scroll">
|
||||||
|
<div className="w-full flex flex-col gap-y-6">
|
||||||
|
<ScriptTag embed={embed} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex w-full justify-between items-center p-6 space-x-2 border-t rounded-b border-gray-500/50">
|
||||||
|
<button
|
||||||
|
onClick={closeModal}
|
||||||
|
type="button"
|
||||||
|
className="px-4 py-2 rounded-lg text-white hover:bg-stone-900 transition-all duration-300"
|
||||||
|
>
|
||||||
|
Close
|
||||||
|
</button>
|
||||||
|
<div hidden={true} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function createScriptTagSnippet(embed, scriptHost, serverHost) {
|
||||||
|
return `<!--
|
||||||
|
Paste this script at the bottom of your HTML before the </body> tag.
|
||||||
|
See more style and config options on our docs
|
||||||
|
https://docs.useanything.com/feature-overview/embed
|
||||||
|
-->
|
||||||
|
<script
|
||||||
|
data-embed-id="${embed.uuid}"
|
||||||
|
data-base-api-url="${serverHost}/api/embed"
|
||||||
|
src="${scriptHost}/embed/anythingllm-chat-widget.min.js">
|
||||||
|
</script>
|
||||||
|
<!-- Script hosted by AnythingLLM (https://useanything.com) -->
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ScriptTag = ({ embed }) => {
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
const scriptHost = import.meta.env.DEV
|
||||||
|
? "http://localhost:3000"
|
||||||
|
: window.location.origin;
|
||||||
|
const serverHost = import.meta.env.DEV
|
||||||
|
? "http://localhost:3001"
|
||||||
|
: window.location.origin;
|
||||||
|
const snippet = createScriptTagSnippet(embed, scriptHost, serverHost);
|
||||||
|
|
||||||
|
const handleClick = () => {
|
||||||
|
window.navigator.clipboard.writeText(snippet);
|
||||||
|
setCopied(true);
|
||||||
|
setTimeout(() => {
|
||||||
|
setCopied(false);
|
||||||
|
}, 2500);
|
||||||
|
showToast("Snippet copied to clipboard!", "success", { clear: true });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="flex flex-col mb-2">
|
||||||
|
<label className="block text-sm font-medium text-white">
|
||||||
|
HTML Script Tag Embed Code
|
||||||
|
</label>
|
||||||
|
<p className="text-slate-300 text-xs">
|
||||||
|
Have your workspace chat embed function like a help desk chat bottom
|
||||||
|
in the corner of your website.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
disabled={copied}
|
||||||
|
onClick={handleClick}
|
||||||
|
className="disabled:border disabled:border-green-300 border border-transparent relative w-full font-mono flex bg-zinc-900 text-white placeholder-white placeholder-opacity-60 text-sm rounded-lg focus:border-white p-2.5"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="flex w-full text-left flex-col gap-y-1 pr-6 pl-4 whitespace-pre-line"
|
||||||
|
dangerouslySetInnerHTML={{
|
||||||
|
__html: hljs.highlight(snippet, {
|
||||||
|
language: "html",
|
||||||
|
ignoreIllegals: true,
|
||||||
|
}).value,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{copied ? (
|
||||||
|
<CheckCircle
|
||||||
|
size={14}
|
||||||
|
className="text-green-300 absolute top-2 right-2"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<CopySimple size={14} className="absolute top-2 right-2" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
|
@ -0,0 +1,121 @@
|
||||||
|
import React, { useState } from "react";
|
||||||
|
import { X } from "@phosphor-icons/react";
|
||||||
|
import {
|
||||||
|
BooleanInput,
|
||||||
|
ChatModeSelection,
|
||||||
|
NumberInput,
|
||||||
|
PermittedDomains,
|
||||||
|
WorkspaceSelection,
|
||||||
|
enforceSubmissionSchema,
|
||||||
|
} from "../../NewEmbedModal";
|
||||||
|
import Embed from "@/models/embed";
|
||||||
|
import showToast from "@/utils/toast";
|
||||||
|
|
||||||
|
export default function EditEmbedModal({ embed, closeModal }) {
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
|
||||||
|
const handleUpdate = async (e) => {
|
||||||
|
setError(null);
|
||||||
|
e.preventDefault();
|
||||||
|
const form = new FormData(e.target);
|
||||||
|
const data = enforceSubmissionSchema(form);
|
||||||
|
const { success, error } = await Embed.updateEmbed(embed.id, data);
|
||||||
|
if (success) {
|
||||||
|
showToast("Embed updated successfully.", "success", { clear: true });
|
||||||
|
setTimeout(() => {
|
||||||
|
window.location.reload();
|
||||||
|
}, 800);
|
||||||
|
}
|
||||||
|
setError(error);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative max-w-2xl max-h-full">
|
||||||
|
<div className="relative bg-main-gradient rounded-lg shadow">
|
||||||
|
<div className="flex items-start justify-between p-4 border-b rounded-t border-gray-500/50">
|
||||||
|
<h3 className="text-xl font-semibold text-white">
|
||||||
|
Update embed #{embed.id}
|
||||||
|
</h3>
|
||||||
|
<button
|
||||||
|
onClick={closeModal}
|
||||||
|
type="button"
|
||||||
|
className="transition-all duration-300 text-gray-400 bg-transparent hover:border-white/60 rounded-lg text-sm p-1.5 ml-auto inline-flex items-center bg-sidebar-button hover:bg-menu-item-selected-gradient hover:border-slate-100 hover:border-opacity-50 border-transparent border"
|
||||||
|
data-modal-hide="staticModal"
|
||||||
|
>
|
||||||
|
<X className="text-gray-300 text-lg" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<form onSubmit={handleUpdate}>
|
||||||
|
<div className="p-6 space-y-6 flex h-auto max-h-[80vh] w-full overflow-y-scroll">
|
||||||
|
<div className="w-full flex flex-col gap-y-6">
|
||||||
|
<WorkspaceSelection defaultValue={embed.workspace.id} />
|
||||||
|
<ChatModeSelection defaultValue={embed.chat_mode} />
|
||||||
|
<PermittedDomains
|
||||||
|
defaultValue={
|
||||||
|
embed.allowlist_domains
|
||||||
|
? JSON.parse(embed.allowlist_domains)
|
||||||
|
: []
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<NumberInput
|
||||||
|
name="max_chats_per_day"
|
||||||
|
title="Max chats per day"
|
||||||
|
hint="Limit the amount of chats this embedded chat can process in a 24 hour period. Zero is unlimited."
|
||||||
|
defaultValue={embed.max_chats_per_day}
|
||||||
|
/>
|
||||||
|
<NumberInput
|
||||||
|
name="max_chats_per_session"
|
||||||
|
title="Max chats per session"
|
||||||
|
hint="Limit the amount of chats a session user can send with this embed in a 24 hour period. Zero is unlimited."
|
||||||
|
defaultValue={embed.max_chats_per_session}
|
||||||
|
/>
|
||||||
|
<BooleanInput
|
||||||
|
name="allow_model_override"
|
||||||
|
title="Enable dynamic model use"
|
||||||
|
hint="Allow setting of the preferred LLM model to override the workspace default."
|
||||||
|
defaultValue={embed.allow_model_override}
|
||||||
|
/>
|
||||||
|
<BooleanInput
|
||||||
|
name="allow_temperature_override"
|
||||||
|
title="Enable dynamic LLM temperature"
|
||||||
|
hint="Allow setting of the LLM temperature to override the workspace default."
|
||||||
|
defaultValue={embed.allow_temperature_override}
|
||||||
|
/>
|
||||||
|
<BooleanInput
|
||||||
|
name="allow_prompt_override"
|
||||||
|
title="Enable Prompt Override"
|
||||||
|
hint="Allow setting of the system prompt to override the workspace default."
|
||||||
|
defaultValue={embed.allow_prompt_override}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{error && <p className="text-red-400 text-sm">Error: {error}</p>}
|
||||||
|
<p className="text-white text-xs md:text-sm pb-8">
|
||||||
|
After creating an embed you will be provided a link that you can
|
||||||
|
publish on your website with a simple
|
||||||
|
<code className="bg-stone-800 text-white mx-1 px-1 rounded-sm">
|
||||||
|
<script>
|
||||||
|
</code>{" "}
|
||||||
|
tag.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex w-full justify-between items-center p-6 space-x-2 border-t rounded-b border-gray-500/50">
|
||||||
|
<button
|
||||||
|
onClick={closeModal}
|
||||||
|
type="button"
|
||||||
|
className="px-4 py-2 rounded-lg text-white hover:bg-stone-900 transition-all duration-300"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="transition-all duration-300 border border-slate-200 px-4 py-2 rounded-lg text-white text-sm items-center flex gap-x-2 hover:bg-slate-200 hover:text-slate-800 focus:ring-gray-800"
|
||||||
|
>
|
||||||
|
Update embed
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
|
@ -0,0 +1,140 @@
|
||||||
|
import { useRef, useState } from "react";
|
||||||
|
import { DotsThreeOutline, LinkSimple } from "@phosphor-icons/react";
|
||||||
|
import showToast from "@/utils/toast";
|
||||||
|
import { useModal } from "@/hooks/useModal";
|
||||||
|
import ModalWrapper from "@/components/ModalWrapper";
|
||||||
|
import Embed from "@/models/embed";
|
||||||
|
import paths from "@/utils/paths";
|
||||||
|
import { nFormatter } from "@/utils/numbers";
|
||||||
|
import EditEmbedModal from "./EditEmbedModal";
|
||||||
|
import CodeSnippetModal from "./CodeSnippetModal";
|
||||||
|
|
||||||
|
export default function EmbedRow({ embed }) {
|
||||||
|
const rowRef = useRef(null);
|
||||||
|
const [enabled, setEnabled] = useState(Number(embed.enabled) === 1);
|
||||||
|
const {
|
||||||
|
isOpen: isSettingsOpen,
|
||||||
|
openModal: openSettingsModal,
|
||||||
|
closeModal: closeSettingsModal,
|
||||||
|
} = useModal();
|
||||||
|
const {
|
||||||
|
isOpen: isSnippetOpen,
|
||||||
|
openModal: openSnippetModal,
|
||||||
|
closeModal: closeSnippetModal,
|
||||||
|
} = useModal();
|
||||||
|
|
||||||
|
const handleSuspend = async () => {
|
||||||
|
if (
|
||||||
|
!window.confirm(
|
||||||
|
`Are you sure you want to disabled this embed?\nOnce disabled the embed will no longer respond to any chat requests.`
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
const { success, error } = await Embed.updateEmbed(embed.id, {
|
||||||
|
enabled: !enabled,
|
||||||
|
});
|
||||||
|
if (!success) showToast(error, "error", { clear: true });
|
||||||
|
if (success) {
|
||||||
|
showToast(
|
||||||
|
`Embed ${enabled ? "has been disabled" : "is active"}.`,
|
||||||
|
"success",
|
||||||
|
{ clear: true }
|
||||||
|
);
|
||||||
|
setEnabled(!enabled);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const handleDelete = async () => {
|
||||||
|
if (
|
||||||
|
!window.confirm(
|
||||||
|
`Are you sure you want to delete this embed?\nOnce deleted this embed will no longer respond to chats or be active.\n\nThis action is irreversible.`
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return false;
|
||||||
|
const { success, error } = await Embed.deleteEmbed(embed.id);
|
||||||
|
if (!success) showToast(error, "error", { clear: true });
|
||||||
|
if (success) {
|
||||||
|
rowRef?.current?.remove();
|
||||||
|
showToast("Embed deleted from system.", "success", { clear: true });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<tr
|
||||||
|
ref={rowRef}
|
||||||
|
className="bg-transparent text-white text-opacity-80 text-sm"
|
||||||
|
>
|
||||||
|
<th
|
||||||
|
scope="row"
|
||||||
|
className="px-6 py-4 whitespace-nowrap flex item-center gap-x-1"
|
||||||
|
>
|
||||||
|
<a
|
||||||
|
href={paths.workspace.chat(embed.workspace.slug)}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="text-white flex items-center hover:underline"
|
||||||
|
>
|
||||||
|
<LinkSimple className="mr-2 w-5 h-5" /> {embed.workspace.name}
|
||||||
|
</a>
|
||||||
|
</th>
|
||||||
|
<th scope="row" className="px-6 py-4 whitespace-nowrap">
|
||||||
|
{nFormatter(embed._count.embed_chats)}
|
||||||
|
</th>
|
||||||
|
<th scope="row" className="px-6 py-4 whitespace-nowrap">
|
||||||
|
<ActiveDomains domainList={embed.allowlist_domains} />
|
||||||
|
</th>
|
||||||
|
<td className="px-6 py-4 flex items-center gap-x-6">
|
||||||
|
<button
|
||||||
|
onClick={openSettingsModal}
|
||||||
|
className="font-medium text-white text-opacity-80 rounded-lg hover:text-white px-2 py-1 hover:text-opacity-60 hover:bg-white hover:bg-opacity-10"
|
||||||
|
>
|
||||||
|
<DotsThreeOutline weight="fill" className="h-5 w-5" />
|
||||||
|
</button>
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
onClick={openSnippetModal}
|
||||||
|
className="font-medium text-blue-600 dark:text-blue-300 px-2 py-1 rounded-lg hover:bg-blue-50 hover:dark:bg-blue-800 hover:dark:bg-opacity-20"
|
||||||
|
>
|
||||||
|
Show Code
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleSuspend}
|
||||||
|
className="font-medium text-orange-600 dark:text-orange-300 px-2 py-1 rounded-lg hover:bg-orange-50 hover:dark:bg-orange-800 hover:dark:bg-opacity-20"
|
||||||
|
>
|
||||||
|
{enabled ? "Disable" : "Enable"}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleDelete}
|
||||||
|
className="font-medium text-red-600 dark:text-red-300 px-2 py-1 rounded-lg hover:bg-red-50 hover:dark:bg-red-800 hover:dark:bg-opacity-20"
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<ModalWrapper isOpen={isSettingsOpen}>
|
||||||
|
<EditEmbedModal embed={embed} closeModal={closeSettingsModal} />
|
||||||
|
</ModalWrapper>
|
||||||
|
<ModalWrapper isOpen={isSnippetOpen}>
|
||||||
|
<CodeSnippetModal embed={embed} closeModal={closeSnippetModal} />
|
||||||
|
</ModalWrapper>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ActiveDomains({ domainList }) {
|
||||||
|
if (!domainList) return <p>all</p>;
|
||||||
|
try {
|
||||||
|
const domains = JSON.parse(domainList);
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-y-2">
|
||||||
|
{domains.map((domain) => {
|
||||||
|
return <p className="font-mono !font-normal">{domain}</p>;
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
return <p>all</p>;
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,321 @@
|
||||||
|
import React, { useEffect, useState } from "react";
|
||||||
|
import { X } from "@phosphor-icons/react";
|
||||||
|
import Workspace from "@/models/workspace";
|
||||||
|
import { TagsInput } from "react-tag-input-component";
|
||||||
|
import Embed from "@/models/embed";
|
||||||
|
|
||||||
|
export function enforceSubmissionSchema(form) {
|
||||||
|
const data = {};
|
||||||
|
for (var [key, value] of form.entries()) {
|
||||||
|
if (!value || value === null) continue;
|
||||||
|
data[key] = value;
|
||||||
|
if (value === "on") data[key] = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Always set value on nullable keys since empty or off will not send anything from form element.
|
||||||
|
if (!data.hasOwnProperty("allowlist_domains")) data.allowlist_domains = null;
|
||||||
|
if (!data.hasOwnProperty("allow_model_override"))
|
||||||
|
data.allow_model_override = false;
|
||||||
|
if (!data.hasOwnProperty("allow_temperature_override"))
|
||||||
|
data.allow_temperature_override = false;
|
||||||
|
if (!data.hasOwnProperty("allow_prompt_override"))
|
||||||
|
data.allow_prompt_override = false;
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function NewEmbedModal({ closeModal }) {
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
|
||||||
|
const handleCreate = async (e) => {
|
||||||
|
setError(null);
|
||||||
|
e.preventDefault();
|
||||||
|
const form = new FormData(e.target);
|
||||||
|
const data = enforceSubmissionSchema(form);
|
||||||
|
const { embed, error } = await Embed.newEmbed(data);
|
||||||
|
if (!!embed) window.location.reload();
|
||||||
|
setError(error);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative w-full max-w-2xl max-h-full">
|
||||||
|
<div className="relative bg-main-gradient rounded-lg shadow">
|
||||||
|
<div className="flex items-start justify-between p-4 border-b rounded-t border-gray-500/50">
|
||||||
|
<h3 className="text-xl font-semibold text-white">
|
||||||
|
Create new embed for workspace
|
||||||
|
</h3>
|
||||||
|
<button
|
||||||
|
onClick={closeModal}
|
||||||
|
type="button"
|
||||||
|
className="transition-all duration-300 text-gray-400 bg-transparent hover:border-white/60 rounded-lg text-sm p-1.5 ml-auto inline-flex items-center bg-sidebar-button hover:bg-menu-item-selected-gradient hover:border-slate-100 hover:border-opacity-50 border-transparent border"
|
||||||
|
data-modal-hide="staticModal"
|
||||||
|
>
|
||||||
|
<X className="text-gray-300 text-lg" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<form onSubmit={handleCreate}>
|
||||||
|
<div className="p-6 space-y-6 flex h-auto max-h-[80vh] w-full overflow-y-scroll">
|
||||||
|
<div className="w-full flex flex-col gap-y-6">
|
||||||
|
<WorkspaceSelection />
|
||||||
|
<ChatModeSelection />
|
||||||
|
<PermittedDomains />
|
||||||
|
<NumberInput
|
||||||
|
name="max_chats_per_day"
|
||||||
|
title="Max chats per day"
|
||||||
|
hint="Limit the amount of chats this embedded chat can process in a 24 hour period. Zero is unlimited."
|
||||||
|
/>
|
||||||
|
<NumberInput
|
||||||
|
name="max_chats_per_session"
|
||||||
|
title="Max chats per session"
|
||||||
|
hint="Limit the amount of chats a session user can send with this embed in a 24 hour period. Zero is unlimited."
|
||||||
|
/>
|
||||||
|
<BooleanInput
|
||||||
|
name="allow_model_override"
|
||||||
|
title="Enable dynamic model use"
|
||||||
|
hint="Allow setting of the preferred LLM model to override the workspace default."
|
||||||
|
/>
|
||||||
|
<BooleanInput
|
||||||
|
name="allow_temperature_override"
|
||||||
|
title="Enable dynamic LLM temperature"
|
||||||
|
hint="Allow setting of the LLM temperature to override the workspace default."
|
||||||
|
/>
|
||||||
|
<BooleanInput
|
||||||
|
name="allow_prompt_override"
|
||||||
|
title="Enable Prompt Override"
|
||||||
|
hint="Allow setting of the system prompt to override the workspace default."
|
||||||
|
/>
|
||||||
|
|
||||||
|
{error && <p className="text-red-400 text-sm">Error: {error}</p>}
|
||||||
|
<p className="text-white text-xs md:text-sm pb-8">
|
||||||
|
After creating an embed you will be provided a link that you can
|
||||||
|
publish on your website with a simple
|
||||||
|
<code className="bg-stone-800 text-white mx-1 px-1 rounded-sm">
|
||||||
|
<script>
|
||||||
|
</code>{" "}
|
||||||
|
tag.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex w-full justify-between items-center p-6 space-x-2 border-t rounded-b border-gray-500/50">
|
||||||
|
<button
|
||||||
|
onClick={closeModal}
|
||||||
|
type="button"
|
||||||
|
className="px-4 py-2 rounded-lg text-white hover:bg-stone-900 transition-all duration-300"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="transition-all duration-300 border border-slate-200 px-4 py-2 rounded-lg text-white text-sm items-center flex gap-x-2 hover:bg-slate-200 hover:text-slate-800 focus:ring-gray-800"
|
||||||
|
>
|
||||||
|
Create embed
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const WorkspaceSelection = ({ defaultValue = null }) => {
|
||||||
|
const [workspaces, setWorkspaces] = useState([]);
|
||||||
|
useEffect(() => {
|
||||||
|
async function fetchWorkspaces() {
|
||||||
|
const _workspaces = await Workspace.all();
|
||||||
|
setWorkspaces(_workspaces);
|
||||||
|
}
|
||||||
|
fetchWorkspaces();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="flex flex-col mb-2">
|
||||||
|
<label
|
||||||
|
htmlFor="workspaceId"
|
||||||
|
className="block text-sm font-medium text-white"
|
||||||
|
>
|
||||||
|
Workspace
|
||||||
|
</label>
|
||||||
|
<p className="text-slate-300 text-xs">
|
||||||
|
This is the workspace your chat window will be based on. All defaults
|
||||||
|
will be inherited from the workspace unless overridden by this config.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<select
|
||||||
|
name="workspaceId"
|
||||||
|
required={true}
|
||||||
|
defaultValue={defaultValue}
|
||||||
|
className="min-w-[15rem] rounded-lg bg-zinc-900 px-4 py-2 text-sm text-white border border-gray-500 focus:ring-blue-500 focus:border-blue-500"
|
||||||
|
>
|
||||||
|
{workspaces.map((workspace) => {
|
||||||
|
return <option value={workspace.id}>{workspace.name}</option>;
|
||||||
|
})}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ChatModeSelection = ({ defaultValue = null }) => {
|
||||||
|
const [chatMode, setChatMode] = useState(defaultValue ?? "query");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="flex flex-col mb-2">
|
||||||
|
<label
|
||||||
|
className="block text-sm font-medium text-white"
|
||||||
|
htmlFor="chat_mode"
|
||||||
|
>
|
||||||
|
Allowed chat method
|
||||||
|
</label>
|
||||||
|
<p className="text-slate-300 text-xs">
|
||||||
|
Set how your chatbot should operate. Query means it will only respond
|
||||||
|
if a document helps answer the query.
|
||||||
|
<br />
|
||||||
|
Chat opens the chat to even general questions and can answer totally
|
||||||
|
unrelated queries to your workspace.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 gap-y-3 flex flex-col">
|
||||||
|
<label
|
||||||
|
className={`transition-all duration-300 w-full h-11 p-2.5 bg-white/10 rounded-lg flex justify-start items-center gap-2.5 cursor-pointer border border-transparent ${
|
||||||
|
chatMode === "chat" ? "border-white border-opacity-40" : ""
|
||||||
|
} hover:border-white/60`}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="chat_mode"
|
||||||
|
value={"chat"}
|
||||||
|
checked={chatMode === "chat"}
|
||||||
|
onChange={(e) => setChatMode(e.target.value)}
|
||||||
|
className="hidden"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className={`w-4 h-4 rounded-full border-2 border-white mr-2 ${
|
||||||
|
chatMode === "chat" ? "bg-white" : ""
|
||||||
|
}`}
|
||||||
|
></div>
|
||||||
|
<div className="text-white text-sm font-medium font-['Plus Jakarta Sans'] leading-tight">
|
||||||
|
Chat: Respond to all questions regardless of context
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
<label
|
||||||
|
className={`transition-all duration-300 w-full h-11 p-2.5 bg-white/10 rounded-lg flex justify-start items-center gap-2.5 cursor-pointer border border-transparent ${
|
||||||
|
chatMode === "query" ? "border-white border-opacity-40" : ""
|
||||||
|
} hover:border-white/60`}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="chat_mode"
|
||||||
|
value={"query"}
|
||||||
|
checked={chatMode === "query"}
|
||||||
|
onChange={(e) => setChatMode(e.target.value)}
|
||||||
|
className="hidden"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className={`w-4 h-4 rounded-full border-2 border-white mr-2 ${
|
||||||
|
chatMode === "query" ? "bg-white" : ""
|
||||||
|
}`}
|
||||||
|
></div>
|
||||||
|
<div className="text-white text-sm font-medium font-['Plus Jakarta Sans'] leading-tight">
|
||||||
|
Query: Only respond to chats related to documents in workspace
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const PermittedDomains = ({ defaultValue = [] }) => {
|
||||||
|
const [domains, setDomains] = useState(defaultValue);
|
||||||
|
const handleChange = (data) => {
|
||||||
|
const validDomains = data
|
||||||
|
.map((input) => {
|
||||||
|
let url = input;
|
||||||
|
if (!url.includes("http://") && !url.includes("https://"))
|
||||||
|
url = `https://${url}`;
|
||||||
|
try {
|
||||||
|
new URL(url);
|
||||||
|
return url;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.filter((u) => !!u);
|
||||||
|
setDomains(validDomains);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="flex flex-col mb-2">
|
||||||
|
<label
|
||||||
|
htmlFor="allowlist_domains"
|
||||||
|
className="block text-sm font-medium text-white"
|
||||||
|
>
|
||||||
|
Restrict requests from domains
|
||||||
|
</label>
|
||||||
|
<p className="text-slate-300 text-xs">
|
||||||
|
This filter will block any requests that come from a domain other than
|
||||||
|
the list below.
|
||||||
|
<br />
|
||||||
|
Leaving this empty means anyone can use your embed on any site.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<input type="hidden" name="allowlist_domains" value={domains.join(",")} />
|
||||||
|
<TagsInput
|
||||||
|
value={domains}
|
||||||
|
onChange={handleChange}
|
||||||
|
placeholder="https://mysite.com, https://useanything.com"
|
||||||
|
classNames={{
|
||||||
|
tag: "bg-blue-300/10 text-zinc-800 m-1",
|
||||||
|
input:
|
||||||
|
"flex bg-zinc-900 text-white placeholder-white placeholder-opacity-60 text-sm rounded-lg focus:border-white p-2.5",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const NumberInput = ({ name, title, hint, defaultValue = 0 }) => {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="flex flex-col mb-2">
|
||||||
|
<label htmlFor={name} className="block text-sm font-medium text-white">
|
||||||
|
{title}
|
||||||
|
</label>
|
||||||
|
<p className="text-slate-300 text-xs">{hint}</p>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
name={name}
|
||||||
|
className="bg-zinc-900 text-white placeholder-white placeholder-opacity-60 text-sm rounded-lg focus:border-white block w-[15rem] p-2.5"
|
||||||
|
min={0}
|
||||||
|
defaultValue={defaultValue}
|
||||||
|
onScroll={(e) => e.target.blur()}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const BooleanInput = ({ name, title, hint, defaultValue = null }) => {
|
||||||
|
const [status, setStatus] = useState(defaultValue ?? false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="flex flex-col mb-2">
|
||||||
|
<label htmlFor={name} className="block text-sm font-medium text-white">
|
||||||
|
{title}
|
||||||
|
</label>
|
||||||
|
<p className="text-slate-300 text-xs">{hint}</p>
|
||||||
|
</div>
|
||||||
|
<label className="relative inline-flex cursor-pointer items-center">
|
||||||
|
<input
|
||||||
|
name={name}
|
||||||
|
type="checkbox"
|
||||||
|
onClick={() => setStatus(!status)}
|
||||||
|
checked={status}
|
||||||
|
className="peer sr-only pointer-events-none"
|
||||||
|
/>
|
||||||
|
<div className="pointer-events-none peer h-6 w-11 rounded-full bg-stone-400 after:absolute after:left-[2px] after:top-[2px] after:h-5 after:w-5 after:rounded-full after:shadow-xl after:border after:border-gray-600 after:bg-white after:box-shadow-md after:transition-all after:content-[''] peer-checked:bg-lime-300 peer-checked:after:translate-x-full peer-checked:after:border-white peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-800" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
|
@ -0,0 +1,103 @@
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import Sidebar, { SidebarMobileHeader } from "@/components/SettingsSidebar";
|
||||||
|
import { isMobile } from "react-device-detect";
|
||||||
|
import * as Skeleton from "react-loading-skeleton";
|
||||||
|
import "react-loading-skeleton/dist/skeleton.css";
|
||||||
|
import { CodeBlock } from "@phosphor-icons/react";
|
||||||
|
import EmbedRow from "./EmbedRow";
|
||||||
|
import NewEmbedModal from "./NewEmbedModal";
|
||||||
|
import { useModal } from "@/hooks/useModal";
|
||||||
|
import ModalWrapper from "@/components/ModalWrapper";
|
||||||
|
import Embed from "@/models/embed";
|
||||||
|
|
||||||
|
export default function EmbedConfigs() {
|
||||||
|
const { isOpen, openModal, closeModal } = useModal();
|
||||||
|
return (
|
||||||
|
<div className="w-screen h-screen overflow-hidden bg-sidebar flex">
|
||||||
|
{!isMobile && <Sidebar />}
|
||||||
|
<div
|
||||||
|
style={{ height: isMobile ? "100%" : "calc(100% - 32px)" }}
|
||||||
|
className="transition-all duration-500 relative md:ml-[2px] md:mr-[16px] md:my-[16px] md:rounded-[26px] bg-main-gradient w-full h-full overflow-y-scroll border-4 border-accent"
|
||||||
|
>
|
||||||
|
{isMobile && <SidebarMobileHeader />}
|
||||||
|
<div className="flex flex-col w-full px-1 md:px-20 md:py-12 py-16">
|
||||||
|
<div className="w-full flex flex-col gap-y-1 pb-6 border-white border-b-2 border-opacity-10">
|
||||||
|
<div className="items-center flex gap-x-4">
|
||||||
|
<p className="text-2xl font-semibold text-white">
|
||||||
|
Embeddable Chat Widgets
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={openModal}
|
||||||
|
className="border border-slate-200 px-4 py-1 rounded-lg text-slate-200 text-sm items-center flex gap-x-2 hover:bg-slate-200 hover:text-slate-800"
|
||||||
|
>
|
||||||
|
<CodeBlock className="h-4 w-4" /> Create embed
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm font-base text-white text-opacity-60">
|
||||||
|
Embeddable chat widgets are public facing chat interfaces that are
|
||||||
|
tied to a single workspace. These allow you to build workspaces
|
||||||
|
that then you can publish to the world.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<EmbedContainer />
|
||||||
|
</div>
|
||||||
|
<ModalWrapper isOpen={isOpen}>
|
||||||
|
<NewEmbedModal closeModal={closeModal} />
|
||||||
|
</ModalWrapper>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function EmbedContainer() {
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [embeds, setEmbeds] = useState([]);
|
||||||
|
useEffect(() => {
|
||||||
|
async function fetchUsers() {
|
||||||
|
const _embeds = await Embed.embeds();
|
||||||
|
setEmbeds(_embeds);
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
fetchUsers();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<Skeleton.default
|
||||||
|
height="80vh"
|
||||||
|
width="100%"
|
||||||
|
highlightColor="#3D4147"
|
||||||
|
baseColor="#2C2F35"
|
||||||
|
count={1}
|
||||||
|
className="w-full p-4 rounded-b-2xl rounded-tr-2xl rounded-tl-sm mt-6"
|
||||||
|
containerClassName="flex w-full"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<table className="md:w-3/4 w-full text-sm text-left rounded-lg mt-5">
|
||||||
|
<thead className="text-white text-opacity-80 text-sm font-bold uppercase border-white border-b border-opacity-60">
|
||||||
|
<tr>
|
||||||
|
<th scope="col" className="px-6 py-3 rounded-tl-lg">
|
||||||
|
Workspace
|
||||||
|
</th>
|
||||||
|
<th scope="col" className="px-6 py-3">
|
||||||
|
Sent Chats
|
||||||
|
</th>
|
||||||
|
<th scope="col" className="px-6 py-3">
|
||||||
|
Active Domains
|
||||||
|
</th>
|
||||||
|
<th scope="col" className="px-6 py-3 rounded-tr-lg">
|
||||||
|
{" "}
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{embeds.map((embed) => (
|
||||||
|
<EmbedRow key={embed.id} embed={embed} />
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
);
|
||||||
|
}
|
|
@ -93,6 +93,12 @@ export default {
|
||||||
apiKeys: () => {
|
apiKeys: () => {
|
||||||
return "/settings/api-keys";
|
return "/settings/api-keys";
|
||||||
},
|
},
|
||||||
|
embedSetup: () => {
|
||||||
|
return `/settings/embed-config`;
|
||||||
|
},
|
||||||
|
embedChats: () => {
|
||||||
|
return `/settings/embed-chats`;
|
||||||
|
},
|
||||||
dataConnectors: {
|
dataConnectors: {
|
||||||
list: () => {
|
list: () => {
|
||||||
return "/settings/data-connectors";
|
return "/settings/data-connectors";
|
||||||
|
|
|
@ -0,0 +1,103 @@
|
||||||
|
const { EmbedChats } = require("../models/embedChats");
|
||||||
|
const { EmbedConfig } = require("../models/embedConfig");
|
||||||
|
const { reqBody, userFromSession } = require("../utils/http");
|
||||||
|
const {
|
||||||
|
validEmbedConfig,
|
||||||
|
validEmbedConfigId,
|
||||||
|
} = require("../utils/middleware/embedMiddleware");
|
||||||
|
const {
|
||||||
|
flexUserRoleValid,
|
||||||
|
ROLES,
|
||||||
|
} = require("../utils/middleware/multiUserProtected");
|
||||||
|
const { validatedRequest } = require("../utils/middleware/validatedRequest");
|
||||||
|
|
||||||
|
function embedManagementEndpoints(app) {
|
||||||
|
if (!app) return;
|
||||||
|
|
||||||
|
app.get(
|
||||||
|
"/embeds",
|
||||||
|
[validatedRequest, flexUserRoleValid([ROLES.admin])],
|
||||||
|
async (_, response) => {
|
||||||
|
try {
|
||||||
|
const embeds = await EmbedConfig.whereWithWorkspace({}, null, {
|
||||||
|
createdAt: "desc",
|
||||||
|
});
|
||||||
|
response.status(200).json({ embeds });
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
response.sendStatus(500).end();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
app.post(
|
||||||
|
"/embeds/new",
|
||||||
|
[validatedRequest, flexUserRoleValid([ROLES.admin])],
|
||||||
|
async (request, response) => {
|
||||||
|
try {
|
||||||
|
const user = userFromSession(request, response);
|
||||||
|
const data = reqBody(request);
|
||||||
|
const { embed, message: error } = await EmbedConfig.new(data, user?.id);
|
||||||
|
response.status(200).json({ embed, error });
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
response.sendStatus(500).end();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
app.post(
|
||||||
|
"/embed/update/:embedId",
|
||||||
|
[validatedRequest, flexUserRoleValid([ROLES.admin]), validEmbedConfigId],
|
||||||
|
async (request, response) => {
|
||||||
|
try {
|
||||||
|
const { embedId } = request.params;
|
||||||
|
const updates = reqBody(request);
|
||||||
|
const { success, error } = await EmbedConfig.update(embedId, updates);
|
||||||
|
response.status(200).json({ success, error });
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
response.sendStatus(500).end();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
app.delete(
|
||||||
|
"/embed/:embedId",
|
||||||
|
[validatedRequest, flexUserRoleValid([ROLES.admin]), validEmbedConfigId],
|
||||||
|
async (request, response) => {
|
||||||
|
try {
|
||||||
|
const { embedId } = request.params;
|
||||||
|
await EmbedConfig.delete({ id: Number(embedId) });
|
||||||
|
response.status(200).json({ success: true, error: null });
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
response.sendStatus(500).end();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
app.post(
|
||||||
|
"/embed/chats",
|
||||||
|
[validatedRequest, flexUserRoleValid([ROLES.admin])],
|
||||||
|
async (_, response) => {
|
||||||
|
try {
|
||||||
|
const { offset = 0, limit = 20 } = reqBody(request);
|
||||||
|
const embedChats = await EmbedChats.whereWithEmbed(
|
||||||
|
{},
|
||||||
|
limit,
|
||||||
|
offset * limit,
|
||||||
|
{ id: "desc" }
|
||||||
|
);
|
||||||
|
const totalChats = await EmbedChats.count();
|
||||||
|
const hasPages = totalChats > (offset + 1) * limit;
|
||||||
|
response.status(200).json({ chats: embedChats, hasPages, totalChats });
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
response.sendStatus(500).end();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { embedManagementEndpoints };
|
|
@ -11,6 +11,7 @@ const { systemEndpoints } = require("./endpoints/system");
|
||||||
const { workspaceEndpoints } = require("./endpoints/workspaces");
|
const { workspaceEndpoints } = require("./endpoints/workspaces");
|
||||||
const { chatEndpoints } = require("./endpoints/chat");
|
const { chatEndpoints } = require("./endpoints/chat");
|
||||||
const { embeddedEndpoints } = require("./endpoints/embed");
|
const { embeddedEndpoints } = require("./endpoints/embed");
|
||||||
|
const { embedManagementEndpoints } = require("./endpoints/embedManagement");
|
||||||
const { getVectorDbClass } = require("./utils/helpers");
|
const { getVectorDbClass } = require("./utils/helpers");
|
||||||
const { adminEndpoints } = require("./endpoints/admin");
|
const { adminEndpoints } = require("./endpoints/admin");
|
||||||
const { inviteEndpoints } = require("./endpoints/invite");
|
const { inviteEndpoints } = require("./endpoints/invite");
|
||||||
|
@ -39,6 +40,7 @@ workspaceEndpoints(apiRouter);
|
||||||
chatEndpoints(apiRouter);
|
chatEndpoints(apiRouter);
|
||||||
adminEndpoints(apiRouter);
|
adminEndpoints(apiRouter);
|
||||||
inviteEndpoints(apiRouter);
|
inviteEndpoints(apiRouter);
|
||||||
|
embedManagementEndpoints(apiRouter);
|
||||||
utilEndpoints(apiRouter);
|
utilEndpoints(apiRouter);
|
||||||
developerEndpoints(app, apiRouter);
|
developerEndpoints(app, apiRouter);
|
||||||
|
|
||||||
|
|
|
@ -115,6 +115,27 @@ const EmbedChats = {
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
whereWithEmbed: async function (
|
||||||
|
clause = {},
|
||||||
|
limit = null,
|
||||||
|
orderBy = null,
|
||||||
|
offset = null
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const chats = await prisma.embed_chats.findMany({
|
||||||
|
where: clause,
|
||||||
|
include: { embed_config: true },
|
||||||
|
...(limit !== null ? { take: limit } : {}),
|
||||||
|
...(offset !== null ? { skip: offset } : {}),
|
||||||
|
...(orderBy !== null ? { orderBy } : {}),
|
||||||
|
});
|
||||||
|
return chats;
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error.message);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
count: async function (clause = {}) {
|
count: async function (clause = {}) {
|
||||||
try {
|
try {
|
||||||
const count = await prisma.embed_chats.count({
|
const count = await prisma.embed_chats.count({
|
||||||
|
|
|
@ -1,8 +1,11 @@
|
||||||
|
const { v4 } = require("uuid");
|
||||||
const prisma = require("../utils/prisma");
|
const prisma = require("../utils/prisma");
|
||||||
|
const { VALID_CHAT_MODE } = require("../utils/chats/stream");
|
||||||
|
|
||||||
const EmbedConfig = {
|
const EmbedConfig = {
|
||||||
writable: [
|
writable: [
|
||||||
// Used for generic updates so we can validate keys in request body
|
// Used for generic updates so we can validate keys in request body
|
||||||
|
"enabled",
|
||||||
"allowlist_domains",
|
"allowlist_domains",
|
||||||
"allow_model_override",
|
"allow_model_override",
|
||||||
"allow_temperature_override",
|
"allow_temperature_override",
|
||||||
|
@ -12,36 +15,73 @@ const EmbedConfig = {
|
||||||
"chat_mode",
|
"chat_mode",
|
||||||
],
|
],
|
||||||
|
|
||||||
new: async function (name = null, creatorId = null) {
|
new: async function (data, creatorId = null) {
|
||||||
// if (!name) return { result: null, message: "name cannot be null" };
|
try {
|
||||||
// try {
|
const embed = await prisma.embed_configs.create({
|
||||||
// const workspace = await prisma.embed_configs.create({
|
data: {
|
||||||
// data: { name, slug },
|
uuid: v4(),
|
||||||
// });
|
enabled: true,
|
||||||
// return { workspace, message: null };
|
chat_mode: validatedCreationData(data?.chat_mode, "chat_mode"),
|
||||||
// } catch (error) {
|
allowlist_domains: validatedCreationData(
|
||||||
// console.error(error.message);
|
data?.allowlist_domains,
|
||||||
// return { workspace: null, message: error.message };
|
"allowlist_domains"
|
||||||
// }
|
),
|
||||||
|
allow_model_override: validatedCreationData(
|
||||||
|
data?.allow_model_override,
|
||||||
|
"allow_model_override"
|
||||||
|
),
|
||||||
|
allow_temperature_override: validatedCreationData(
|
||||||
|
data?.allow_temperature_override,
|
||||||
|
"allow_temperature_override"
|
||||||
|
),
|
||||||
|
allow_prompt_override: validatedCreationData(
|
||||||
|
data?.allow_prompt_override,
|
||||||
|
"allow_prompt_override"
|
||||||
|
),
|
||||||
|
max_chats_per_day: validatedCreationData(
|
||||||
|
data?.max_chats_per_day,
|
||||||
|
"max_chats_per_day"
|
||||||
|
),
|
||||||
|
max_chats_per_session: validatedCreationData(
|
||||||
|
data?.max_chats_per_session,
|
||||||
|
"max_chats_per_session"
|
||||||
|
),
|
||||||
|
createdBy: Number(creatorId) ?? null,
|
||||||
|
workspace: {
|
||||||
|
connect: { id: Number(data.workspaceId) },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return { embed, message: null };
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error.message);
|
||||||
|
return { embed: null, message: error.message };
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
update: async function (id = null, data = {}) {
|
update: async function (embedId = null, data = {}) {
|
||||||
// if (!id) throw new Error("No workspace id provided for update");
|
if (!embedId) throw new Error("No embed id provided for update");
|
||||||
// const validKeys = Object.keys(data).filter((key) =>
|
const validKeys = Object.keys(data).filter((key) =>
|
||||||
// this.writable.includes(key)
|
this.writable.includes(key)
|
||||||
// );
|
);
|
||||||
// if (validKeys.length === 0)
|
if (validKeys.length === 0)
|
||||||
// return { workspace: { id }, message: "No valid fields to update!" };
|
return { embed: { id }, message: "No valid fields to update!" };
|
||||||
// try {
|
|
||||||
// const workspace = await prisma.embed_configs.update({
|
const updates = {};
|
||||||
// where: { id },
|
validKeys.map((key) => {
|
||||||
// data,
|
updates[key] = validatedCreationData(data[key], key);
|
||||||
// });
|
});
|
||||||
// return { workspace, message: null };
|
|
||||||
// } catch (error) {
|
try {
|
||||||
// console.error(error.message);
|
await prisma.embed_configs.update({
|
||||||
// return { workspace: null, message: error.message };
|
where: { id: Number(embedId) },
|
||||||
// }
|
data: updates,
|
||||||
|
});
|
||||||
|
return { success: true, error: null };
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error.message);
|
||||||
|
return { success: false, error: error.message };
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
get: async function (clause = {}) {
|
get: async function (clause = {}) {
|
||||||
|
@ -99,6 +139,30 @@ const EmbedConfig = {
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
whereWithWorkspace: async function (
|
||||||
|
clause = {},
|
||||||
|
limit = null,
|
||||||
|
orderBy = null
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const results = await prisma.embed_configs.findMany({
|
||||||
|
where: clause,
|
||||||
|
include: {
|
||||||
|
workspace: true,
|
||||||
|
_count: {
|
||||||
|
select: { embed_chats: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
...(limit !== null ? { take: limit } : {}),
|
||||||
|
...(orderBy !== null ? { orderBy } : {}),
|
||||||
|
});
|
||||||
|
return results;
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error.message);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
// Will return null if process should be skipped
|
// Will return null if process should be skipped
|
||||||
// an empty array means the system will check. This
|
// an empty array means the system will check. This
|
||||||
// prevents a bad parse from allowing all requests
|
// prevents a bad parse from allowing all requests
|
||||||
|
@ -114,4 +178,57 @@ const EmbedConfig = {
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const BOOLEAN_KEYS = [
|
||||||
|
"allow_model_override",
|
||||||
|
"allow_temperature_override",
|
||||||
|
"allow_prompt_override",
|
||||||
|
"enabled",
|
||||||
|
];
|
||||||
|
|
||||||
|
const NUMBER_KEYS = ["max_chats_per_day", "max_chats_per_session"];
|
||||||
|
|
||||||
|
// Helper to validate a data object strictly into the proper format
|
||||||
|
function validatedCreationData(value, field) {
|
||||||
|
if (field === "chat_mode") {
|
||||||
|
if (!value || !VALID_CHAT_MODE.includes(value)) return "query";
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (field === "allowlist_domains") {
|
||||||
|
try {
|
||||||
|
if (!value) return null;
|
||||||
|
return JSON.stringify(
|
||||||
|
// Iterate and force all domains to URL object
|
||||||
|
// and stringify the result.
|
||||||
|
value
|
||||||
|
.split(",")
|
||||||
|
.map((input) => {
|
||||||
|
let url = input;
|
||||||
|
if (!url.includes("http://") && !url.includes("https://"))
|
||||||
|
url = `https://${url}`;
|
||||||
|
try {
|
||||||
|
new URL(url);
|
||||||
|
return url;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.filter((u) => !!u)
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (BOOLEAN_KEYS.includes(field)) {
|
||||||
|
return value === true || value === false ? value : false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (NUMBER_KEYS.includes(field)) {
|
||||||
|
return isNaN(value) || Number(value) <= 0 ? null : Number(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
module.exports = { EmbedConfig };
|
module.exports = { EmbedConfig };
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
const { v4: uuidv4 } = require("uuid");
|
const { v4: uuidv4 } = require("uuid");
|
||||||
const { VALID_CHAT_MODE, writeResponseChunk } = require("../chats/stream");
|
const { VALID_CHAT_MODE } = require("../chats/stream");
|
||||||
const { EmbedChats } = require("../../models/embedChats");
|
const { EmbedChats } = require("../../models/embedChats");
|
||||||
const { EmbedConfig } = require("../../models/embedConfig");
|
const { EmbedConfig } = require("../../models/embedConfig");
|
||||||
const { reqBody } = require("../http");
|
const { reqBody } = require("../http");
|
||||||
|
@ -28,6 +28,19 @@ function setConnectionMeta(request, response, next) {
|
||||||
next();
|
next();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function validEmbedConfigId(request, response, next) {
|
||||||
|
const { embedId } = request.params;
|
||||||
|
|
||||||
|
const embed = await EmbedConfig.get({ id: Number(embedId) });
|
||||||
|
if (!embed) {
|
||||||
|
response.sendStatus(404).end();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
response.locals.embedConfig = embed;
|
||||||
|
next();
|
||||||
|
}
|
||||||
|
|
||||||
async function canRespond(request, response, next) {
|
async function canRespond(request, response, next) {
|
||||||
const embed = response.locals.embedConfig;
|
const embed = response.locals.embedConfig;
|
||||||
if (!embed) {
|
if (!embed) {
|
||||||
|
@ -50,7 +63,7 @@ async function canRespond(request, response, next) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if requester hostname is in the valid allowlist of domains.
|
// Check if requester hostname is in the valid allowlist of domains.
|
||||||
const host = request.hostname;
|
const host = request.headers.origin ?? "";
|
||||||
const allowedHosts = EmbedConfig.parseAllowedHosts(embed);
|
const allowedHosts = EmbedConfig.parseAllowedHosts(embed);
|
||||||
if (allowedHosts !== null && !allowedHosts.includes(host)) {
|
if (allowedHosts !== null && !allowedHosts.includes(host)) {
|
||||||
response.status(401).json({
|
response.status(401).json({
|
||||||
|
@ -134,5 +147,6 @@ async function canRespond(request, response, next) {
|
||||||
module.exports = {
|
module.exports = {
|
||||||
setConnectionMeta,
|
setConnectionMeta,
|
||||||
validEmbedConfig,
|
validEmbedConfig,
|
||||||
|
validEmbedConfigId,
|
||||||
canRespond,
|
canRespond,
|
||||||
};
|
};
|
||||||
|
|
Loading…
Reference in New Issue