mirror of
https://github.com/Mintplex-Labs/anything-llm.git
synced 2024-11-13 02:00:10 +01:00
Admin embed config page
This commit is contained in:
parent
efc54be4ae
commit
b219c5df0e
3
.vscode/settings.json
vendored
3
.vscode/settings.json
vendored
@ -1,6 +1,9 @@
|
||||
{
|
||||
"cSpell.words": [
|
||||
"anythingllm",
|
||||
"Dockerized",
|
||||
"Embeddable",
|
||||
"hljs",
|
||||
"Langchain",
|
||||
"Milvus",
|
||||
"Ollama",
|
||||
|
@ -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>
|
@ -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": {
|
||||
|
@ -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: [
|
||||
|
38
frontend/public/embed/anythingllm-chat-widget.min.js
vendored
Normal file
38
frontend/public/embed/anythingllm-chat-widget.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
@ -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"
|
||||
|
@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
57
frontend/src/models/embed.js
Normal file
57
frontend/src/models/embed.js
Normal 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;
|
@ -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>
|
||||
);
|
||||
};
|
103
frontend/src/pages/GeneralSettings/EmbedConfigs/index.jsx
Normal file
103
frontend/src/pages/GeneralSettings/EmbedConfigs/index.jsx
Normal 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>
|
||||
);
|
||||
}
|
@ -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";
|
||||
|
103
server/endpoints/embedManagement.js
Normal file
103
server/endpoints/embedManagement.js
Normal 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 };
|
@ -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);
|
||||
|
||||
|
@ -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({
|
||||
|
@ -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 };
|
||||
|
@ -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,
|
||||
};
|
||||
|
Loading…
Reference in New Issue
Block a user