Admin embed config page

This commit is contained in:
timothycarambat 2024-02-02 11:12:06 -08:00
parent efc54be4ae
commit b219c5df0e
19 changed files with 1284 additions and 47 deletions

View File

@ -1,6 +1,9 @@
{
"cSpell.words": [
"anythingllm",
"Dockerized",
"Embeddable",
"hljs",
"Langchain",
"Milvus",
"Ollama",

View File

@ -3,9 +3,9 @@
<body>
<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"
src="/dist/embedded-anything-llm.umd.js">
</script>
<!-- <script data-embed-id="example-uuid" data-base-api-url='http://localhost:3001/api/embed' data-open-on-load="on"
src="/dist/anythingllm-chat-widget.js">
</script> -->
</body>
</html>

View File

@ -5,8 +5,9 @@
"scripts": {
"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:build": "vite build && cat src/static/tailwind@3.4.1.js >> dist/embedded-anything-llm.umd.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",
"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/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"
},
"dependencies": {

View File

@ -33,7 +33,7 @@ export default defineConfig({
entry: "src/main.jsx",
name: "EmbeddedAnythingLLM",
formats: ["umd"],
fileName: (format) => `embedded-anything-llm.${format}.js`
fileName: (_format) => `anythingllm-chat-widget.js`
},
rollupOptions: {
external: [

File diff suppressed because one or more lines are too long

View File

@ -41,6 +41,10 @@ const DataConnectors = lazy(
const DataConnectorSetup = lazy(
() => import("@/pages/GeneralSettings/DataConnectors/Connectors")
);
const EmbedConfigSetup = lazy(
() => import("@/pages/GeneralSettings/EmbedConfigs")
);
const EmbedChats = lazy(() => import("@/pages/Admin/Users"));
export default function App() {
return (
@ -70,6 +74,14 @@ export default function App() {
path="/settings/vector-database"
element={<AdminRoute Component={GeneralVectorDatabase} />}
/>
<Route
path="/settings/embed-config"
element={<AdminRoute Component={EmbedConfigSetup} />}
/>
<Route
path="/settings/embed-chats"
element={<AdminRoute Component={EmbedChats} />}
/>
{/* Manager */}
<Route
path="/settings/security"

View File

@ -19,6 +19,8 @@ import {
List,
FileCode,
Plugs,
CodeBlock,
Barcode,
} from "@phosphor-icons/react";
import useUser from "@/hooks/useUser";
import { USER_BACKGROUND_COLOR } from "@/utils/constants";
@ -146,6 +148,27 @@ export default function SettingsSidebar() {
flex={true}
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
href={paths.settings.security()}
btnText="Security"
@ -365,6 +388,27 @@ export function SidebarMobileHeader() {
flex={true}
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
href={paths.settings.security()}
btnText="Security"
@ -418,10 +462,13 @@ const Option = ({
btnText,
icon,
href,
childLinks = [],
flex = false,
user = null,
allowedRole = [],
subOptions = null,
}) => {
const hasActiveChild = childLinks.includes(window.location.pathname);
const isActive = window.location.pathname === href;
// Option only for multi-user
@ -430,10 +477,11 @@ const Option = ({
// Option is dual-mode, but user exists, we need to check permissions
if (flex && !!user && !allowedRole.includes(user?.role)) return null;
return (
<div className="flex gap-x-2 items-center justify-between text-white">
<a
href={href}
className={`
<>
<div className="flex gap-x-2 items-center justify-between text-white">
<a
href={href}
className={`
transition-all duration-[200ms]
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"
}
`}
>
{React.cloneElement(icon, { weight: isActive ? "fill" : "regular" })}
<p className="text-sm leading-loose text-opacity-60 whitespace-nowrap overflow-hidden ">
{btnText}
</p>
</a>
</div>
>
{React.cloneElement(icon, { weight: isActive ? "fill" : "regular" })}
<p className="text-sm leading-loose text-opacity-60 whitespace-nowrap overflow-hidden ">
{btnText}
</p>
</a>
</div>
{!!subOptions && (isActive || hasActiveChild) && (
<div
className={`ml-4 ${
hasActiveChild ? "" : "border-l-2 border-slate-400"
} rounded-r-lg`}
>
{subOptions}
</div>
)}
</>
);
};

View File

@ -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;

View File

@ -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>
);
};

View File

@ -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">
&lt;script&gt;
</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>
);
}

View File

@ -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>;
}
}

View File

@ -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">
&lt;script&gt;
</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>
);
};

View File

@ -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>
);
}

View File

@ -93,6 +93,12 @@ export default {
apiKeys: () => {
return "/settings/api-keys";
},
embedSetup: () => {
return `/settings/embed-config`;
},
embedChats: () => {
return `/settings/embed-chats`;
},
dataConnectors: {
list: () => {
return "/settings/data-connectors";

View File

@ -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 };

View File

@ -11,6 +11,7 @@ const { systemEndpoints } = require("./endpoints/system");
const { workspaceEndpoints } = require("./endpoints/workspaces");
const { chatEndpoints } = require("./endpoints/chat");
const { embeddedEndpoints } = require("./endpoints/embed");
const { embedManagementEndpoints } = require("./endpoints/embedManagement");
const { getVectorDbClass } = require("./utils/helpers");
const { adminEndpoints } = require("./endpoints/admin");
const { inviteEndpoints } = require("./endpoints/invite");
@ -39,6 +40,7 @@ workspaceEndpoints(apiRouter);
chatEndpoints(apiRouter);
adminEndpoints(apiRouter);
inviteEndpoints(apiRouter);
embedManagementEndpoints(apiRouter);
utilEndpoints(apiRouter);
developerEndpoints(app, apiRouter);

View File

@ -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 = {}) {
try {
const count = await prisma.embed_chats.count({

View File

@ -1,8 +1,11 @@
const { v4 } = require("uuid");
const prisma = require("../utils/prisma");
const { VALID_CHAT_MODE } = require("../utils/chats/stream");
const EmbedConfig = {
writable: [
// Used for generic updates so we can validate keys in request body
"enabled",
"allowlist_domains",
"allow_model_override",
"allow_temperature_override",
@ -12,36 +15,73 @@ const EmbedConfig = {
"chat_mode",
],
new: async function (name = null, creatorId = null) {
// if (!name) return { result: null, message: "name cannot be null" };
// try {
// const workspace = await prisma.embed_configs.create({
// data: { name, slug },
// });
// return { workspace, message: null };
// } catch (error) {
// console.error(error.message);
// return { workspace: null, message: error.message };
// }
new: async function (data, creatorId = null) {
try {
const embed = await prisma.embed_configs.create({
data: {
uuid: v4(),
enabled: true,
chat_mode: validatedCreationData(data?.chat_mode, "chat_mode"),
allowlist_domains: validatedCreationData(
data?.allowlist_domains,
"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 = {}) {
// if (!id) throw new Error("No workspace id provided for update");
// const validKeys = Object.keys(data).filter((key) =>
// this.writable.includes(key)
// );
// if (validKeys.length === 0)
// return { workspace: { id }, message: "No valid fields to update!" };
// try {
// const workspace = await prisma.embed_configs.update({
// where: { id },
// data,
// });
// return { workspace, message: null };
// } catch (error) {
// console.error(error.message);
// return { workspace: null, message: error.message };
// }
update: async function (embedId = null, data = {}) {
if (!embedId) throw new Error("No embed id provided for update");
const validKeys = Object.keys(data).filter((key) =>
this.writable.includes(key)
);
if (validKeys.length === 0)
return { embed: { id }, message: "No valid fields to update!" };
const updates = {};
validKeys.map((key) => {
updates[key] = validatedCreationData(data[key], key);
});
try {
await prisma.embed_configs.update({
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 = {}) {
@ -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
// an empty array means the system will check. This
// 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 };

View File

@ -1,5 +1,5 @@
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 { EmbedConfig } = require("../../models/embedConfig");
const { reqBody } = require("../http");
@ -28,6 +28,19 @@ function setConnectionMeta(request, response, 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) {
const embed = response.locals.embedConfig;
if (!embed) {
@ -50,7 +63,7 @@ async function canRespond(request, response, next) {
}
// 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);
if (allowedHosts !== null && !allowedHosts.includes(host)) {
response.status(401).json({
@ -134,5 +147,6 @@ async function canRespond(request, response, next) {
module.exports = {
setConnectionMeta,
validEmbedConfig,
validEmbedConfigId,
canRespond,
};