[FEAT] Embedded AnythingLLM (#656)

* WIP embedded app

* WIP got response from backend in embedded app

* WIP streaming prints to embedded app

* implemented streaming and tailwind min for styling into embedded app

* WIP embedded app history functional

* load params from script tag into embedded app

* rough in modularization of embed chat
cleanup dev process for easier dev support
move all chat to components
todo: build process
todo: backend support

* remove eslint config

* Implement models and cleanup embed chat endpoints
Improve build process for embed
prod minification and bundle size awareness
WIP

* forgot files

* rename to embed folder

* introduce chat modal styles

* add middleware validations on embed chat

* auto open param and default greeting

* reset chat history

* Admin embed config page

* Admin Embed Chats mgmt page

* update embed

* nonpriv

* more style support
reopen if chat was last opened

* update comments

* remove unused imports

* allow change of workspace for embedconfig

* update failure to lookup message

* update reset script

* update instructions

* Add more styling options
Add sponsor text at bottom
Support dynamic container height
Loading animations

* publish new embed script

* Add back syntax highlighting and keep bundle small via dynamic script build

* add hint

* update readme

* update copy model for snippet with link to styles

---------

Co-authored-by: timothycarambat <rambat1010@gmail.com>
This commit is contained in:
Sean Hatfield 2024-02-05 14:21:34 -08:00 committed by GitHub
parent 146385bf41
commit 1846a99b93
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
60 changed files with 7328 additions and 15 deletions

View File

@ -1,7 +1,10 @@
{
"cSpell.words": [
"anythingllm",
"Astra",
"Dockerized",
"Embeddable",
"hljs",
"Langchain",
"Milvus",
"Ollama",

View File

@ -48,11 +48,11 @@ AnythingLLM divides your documents into objects called `workspaces`. A Workspace
Some cool features of AnythingLLM
- **Multi-user instance support and permissioning**
- **_New_** [Custom Embeddable Chat widget for your website](./embed/README.md)
- Multiple document type support (PDF, TXT, DOCX, etc)
- Manage documents in your vector database from a simple UI
- Two chat modes `conversation` and `query`. Conversation retains previous questions and amendments. Query is simple QA against your documents
- In-chat citations linked to the original document source and text
- Simple technology stack for fast iteration
- In-chat citations
- 100% Cloud deployment ready.
- "Bring your own LLM" model.
- Extremely efficient cost-saving measures for managing very large documents. You'll never pay to embed a massive document or transcript more than once. 90% more cost effective than other document chatbot solutions.

25
embed/.gitignore vendored Normal file
View File

@ -0,0 +1,25 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
!yarn.lock
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

9
embed/.prettierignore Normal file
View File

@ -0,0 +1,9 @@
# defaults
**/.git
**/.svn
**/.hg
**/node_modules
**/dist
**/static/**
src/utils/chat/hljs.js

90
embed/README.md Normal file
View File

@ -0,0 +1,90 @@
# AnythingLLM Embedded Chat Widget
> [!WARNING]
> The use of the AnythingLLM embed is currently in beta. Please request a feature or
> report a bug via a Github Issue if you have any issues.
> [!WARNING]
> The core AnythingLLM team publishes a pre-built version of the script that is bundled
> with the main application. You can find it at the frontend URL `/embed/anythingllm-chat-widget.min.js`.
> You should only be working in this repo if you are wanting to build your own custom embed.
This folder of AnythingLLM contains the source code for how the embedded version of AnythingLLM works to provide a public facing interface of your workspace.
The AnythingLLM Embedded chat widget allows you to expose a workspace and its embedded knowledge base as a chat bubble via a `<script>` or `<iframe>` element that you can embed in a website or HTML.
### Security
- Users will _not_ be able to view or read context snippets like they can in the core AnythingLLM application
- Users are assigned a random session ID that they use to persist a chat session.
- **Recommended** You can limit both the number of chats an embedding can process **and** per-session.
_by using the AnythingLLM embedded chat widget you are responsible for securing and configuration of the embed as to not allow excessive chat model abuse of your instance_
### Developer Setup
- `cd embed` from the root of the repo
- `yarn` to install all dev and script dependencies
- `yarn dev` to boot up an example HTML page to use the chat embed widget.
While in development mode (`yarn dev`) the script will rebuild on any changes to files in the `src` directory. Ensure that the required keys for the development embed are accurate and set.
`yarn build` will compile and minify your build of the script. You can then host and link your built script wherever you like.
## Integrations & Embed Types
### `<script>` tag HTML embed
The primary way of embedding a workspace as a chat widget is via a simple `<script>`
```html
<!--
An example of a script tag embed
REQUIRED data attributes:
data-embed-id // The unique id of your embed with its default settings
data-base-api-url // The URL of your anythingLLM instance backend
-->
<script
data-embed-id="5fc05aaf-2f2c-4c84-87a3-367a4692c1ee"
data-base-api-url="http://localhost:3001/api/embed"
src="http://localhost:3000/embed/anythingllm-chat-widget.min.js">
</script>
```
### `<script>` Customization Options
**LLM Overrides**
- `data-prompt` — Override the chat window with a custom system prompt. This is not visible to the user. If undefined it will use the embeds attached workspace system prompt.
- `data-model` — Override the chat model used for responses. This must be a valid model string for your AnythingLLM LLM provider. If unset it will use the embeds attached workspace model selection or the system setting.
- `data-temperature` — Override the chat model temperature. This must be a valid value for your AnythingLLM LLM provider. If unset it will use the embeds attached workspace model temperature or the system setting.
**Style Overrides**
- `data-chat-icon` — The chat bubble icon show when chat is closed. Options are `plus`, `chatCircle`, `support`, `search2`, `search`, `magic`.
- `data-button-color` — The chat bubble background color shown when chat is closed. Value must be hex color code.
- `data-user-bg-color` — The background color of the user chat bubbles when chatting. Value must be hex color code.
- `data-assistant-bg-color` — The background color of the assistant response chat bubbles when chatting. Value must be hex color code.
- `data-brand-image-url` — URL to image that will be show at the top of the chat when chat is open.
- `data-greeting` — Default text message to be shown when chat is opened and no previous message history is found.
- `data-no-sponsor` — Setting this attribute to anything will hide the custom or default sponsor at the bottom of an open chat window.
- `data-sponsor-link` — A clickable link in the sponsor section in the footer of an open chat window.
- `data-sponsor-text` — The text displays in sponsor text in the footer of an open chat window.
**Behavior Overrides**
- `data-open-on-load` — Once loaded, open the chat as default. It can still be closed by the user.
- `data-support-email` — Shows a support email that the user can used to draft an email via the "three dot" menu in the top right. Option will not appear if it is not set.
### `<iframe>` tag HTML embed
_work in progress_
### `<iframe>` Customization Options
_work in progress_

13
embed/index.html Normal file
View File

@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<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/anythingllm-chat-widget.js"> USE THIS SRC FOR DEVELOPMENT SO CHANGES APPEAR!
</script>
-->
</body>
</html>

12
embed/jsconfig.json Normal file
View File

@ -0,0 +1,12 @@
{
"compilerOptions": {
"module": "commonjs",
"target": "esnext",
"jsx": "react",
"paths": {
"@/*": [
"./src/*"
],
}
}
}

43
embed/package.json Normal file
View File

@ -0,0 +1,43 @@
{
"name": "anythingllm-embedded-chat",
"private": false,
"type": "module",
"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/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": {
"@microsoft/fetch-event-source": "^2.0.1",
"@phosphor-icons/react": "^2.0.13",
"dompurify": "^3.0.8",
"he": "^1.2.0",
"highlight.js": "^11.9.0",
"lodash.debounce": "^4.0.8",
"markdown-it": "^13.0.1",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"uuid": "^9.0.1"
},
"devDependencies": {
"@rollup/plugin-image": "^3.0.3",
"@types/react": "^18.2.37",
"@types/react-dom": "^18.2.15",
"@vitejs/plugin-react": "^4.2.0",
"autoprefixer": "^10.4.14",
"eslint": "^8.53.0",
"eslint-plugin-react": "^7.33.2",
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-react-refresh": "^0.4.4",
"globals": "^13.21.0",
"nodemon": "^2.0.22",
"prettier": "^3.0.3",
"serve": "^14.2.1",
"terser": "^5.27.0",
"vite": "^5.0.0",
"vite-plugin-singlefile": "^0.13.5"
}
}

View File

@ -0,0 +1,35 @@
// What is this script?
// We want to support code syntax highlighting in the embed modal, but we cannot afford to have the static build
// be large in size. To prevent HighlightJs from loading all 193+ language stylings and bloating the script, we instead take a large subset that
// covers most languages and then dynamically build and register each language since HLJS cannot just register with an array of langs.
// Since the embed is a single script - we need to statically import each library and register that associate language.
// we can then export this as a custom implementation of HLJS and call it a day and keep the bundle small.
import fs from 'fs'
const SUPPORTED_HIGHLIGHTS = ['apache', 'bash', 'c', 'cpp', 'csharp', 'css', 'diff', 'go', 'graphql', 'ini', 'java', 'javascript', 'json', 'kotlin', 'less', 'lua', 'makefile', 'markdown', 'nginx', 'objectivec', 'perl', 'pgsql', 'php', 'php-template', 'plaintext', 'python', 'python-repl', 'r', 'ruby', 'rust', 'scss', 'shell', 'sql', 'swift', 'typescript', 'vbnet', 'wasm', 'xml', 'yaml'];
function quickClean(input) {
return input.replace(/[^a-zA-Z0-9]/g, '');
}
let content = `/*
This is a dynamically generated file to help de-bloat the app since this script is a static bundle.
You should not modify this file directly. You can regenerate it with "node scripts/updateHljs.mjd" from the embed folder.
Last generated ${(new Date).toDateString()}
----------------------
*/\n\n`
content += 'import hljs from "highlight.js/lib/core";\n';
SUPPORTED_HIGHLIGHTS.forEach((lang) => {
content += `import ${quickClean(lang)}HljsSupport from 'highlight.js/lib/languages/${lang}'\n`;
});
SUPPORTED_HIGHLIGHTS.forEach((lang) => {
content += ` hljs.registerLanguage('${lang}', ${quickClean(lang)}HljsSupport)\n`;
})
content += `// The above should now register on the languages we wish to support statically.\n`;
content += `export const staticHljs = hljs;\n`
fs.writeFileSync('src/utils/chat/hljs.js', content, { encoding: 'utf8' })
console.log(`Static build of HLJS completed - src/utils/chat/hljs.js`)

52
embed/src/App.jsx Normal file
View File

@ -0,0 +1,52 @@
import useGetScriptAttributes from "@/hooks/useScriptAttributes";
import useSessionId from "@/hooks/useSessionId";
import useOpenChat from "@/hooks/useOpen";
import Head from "@/components/Head";
import OpenButton from "@/components/OpenButton";
import ChatWindow from "./components/ChatWindow";
import { useEffect } from "react";
export default function App() {
const { isChatOpen, toggleOpenChat } = useOpenChat();
const embedSettings = useGetScriptAttributes();
const sessionId = useSessionId();
useEffect(() => {
if (embedSettings.openOnLoad === "on") {
toggleOpenChat(true);
}
}, [embedSettings.loaded]);
if (!embedSettings.loaded) return null;
return (
<>
<Head />
<div className="fixed bottom-0 right-0 mb-4 mr-4 z-50">
<div
style={{
width: isChatOpen ? 320 : "auto",
height: isChatOpen ? "93vh" : "auto",
}}
className={`${
isChatOpen
? "max-w-md px-4 py-2 bg-white rounded-lg border shadow-lg w-72"
: "w-16 h-16 rounded-full"
}`}
>
{isChatOpen && (
<ChatWindow
closeChat={() => toggleOpenChat(false)}
settings={embedSettings}
sessionId={sessionId}
/>
)}
<OpenButton
settings={embedSettings}
isOpen={isChatOpen}
toggleOpen={() => toggleOpenChat(true)}
/>
</div>
</div>
</>
);
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.2 KiB

View File

@ -0,0 +1,43 @@
import useCopyText from "@/hooks/useCopyText";
import { Check, ClipboardText } from "@phosphor-icons/react";
import { memo } from "react";
import { Tooltip } from "react-tooltip";
const Actions = ({ message }) => {
return (
<div className="flex justify-start items-center gap-x-4">
<CopyMessage message={message} />
{/* Other actions to go here later. */}
</div>
);
};
function CopyMessage({ message }) {
const { copied, copyText } = useCopyText();
return (
<>
<div className="mt-3 relative">
<button
data-tooltip-id="copy-assistant-text"
data-tooltip-content="Copy"
className="text-zinc-300"
onClick={() => copyText(message)}
>
{copied ? (
<Check size={18} className="mb-1" />
) : (
<ClipboardText size={18} className="mb-1" />
)}
</button>
</div>
<Tooltip
id="copy-assistant-text"
place="bottom"
delayShow={300}
className="tooltip !text-xs"
/>
</>
);
}
export default memo(Actions);

View File

@ -0,0 +1,60 @@
import React, { memo, forwardRef } from "react";
import { Warning } from "@phosphor-icons/react";
// import Actions from "./Actions";
import renderMarkdown from "@/utils/chat/markdown";
import { embedderSettings } from "@/main";
import { v4 } from "uuid";
import createDOMPurify from "dompurify";
const DOMPurify = createDOMPurify(window);
const HistoricalMessage = forwardRef(
({ uuid = v4(), message, role, sources = [], error = false }, ref) => {
return (
<div
key={uuid}
ref={ref}
className={`flex rounded-lg justify-center items-end w-full h-fit ${
error
? "bg-red-200"
: role === "user"
? embedderSettings.USER_BACKGROUND_COLOR
: embedderSettings.AI_BACKGROUND_COLOR
}`}
>
<div
style={{ wordBreak: "break-word" }}
className={`py-2 px-2 w-full flex flex-col`}
>
<div className="flex">
{error ? (
<div className="p-2 rounded-lg bg-red-50 text-red-500">
<span className={`inline-block `}>
<Warning className="h-4 w-4 mb-1 inline-block" /> Could not
respond to message.
</span>
<p className="text-xs font-mono mt-2 border-l-2 border-red-500 pl-2 bg-red-300 p-2 rounded-sm">
{error}
</p>
</div>
) : (
<span
className={`whitespace-pre-line text-white font-normal text-sm md:text-sm flex flex-col gap-y-1 mt-2`}
dangerouslySetInnerHTML={{
__html: DOMPurify.sanitize(renderMarkdown(message)),
}}
/>
)}
</div>
{/* {role === "assistant" && !error && (
<div className="flex gap-x-5">
<div className="relative w-[35px] h-[35px] rounded-full flex-shrink-0 overflow-hidden" />
<Actions message={DOMPurify.sanitize(message)} />
</div>
)} */}
</div>
</div>
);
}
);
export default memo(HistoricalMessage);

View File

@ -0,0 +1,65 @@
import { forwardRef, memo } from "react";
import { Warning } from "@phosphor-icons/react";
import renderMarkdown from "@/utils/chat/markdown";
import { embedderSettings } from "@/main";
const PromptReply = forwardRef(
({ uuid, reply, pending, error, sources = [] }, ref) => {
if (!reply && sources.length === 0 && !pending && !error) return null;
if (pending) {
return (
<div
ref={ref}
className={`flex justify-center items-end rounded-lg w-full ${embedderSettings.AI_BACKGROUND_COLOR}`}
>
<div className="py-2 px-2 w-full flex flex-col">
<div className="flex gap-x-5">
<div className="mt-3 ml-5 dot-falling"></div>
</div>
</div>
</div>
);
}
if (error) {
return (
<div className={`flex justify-center items-end w-full bg-red-200`}>
<div className="py-2 px-4 w-full flex gap-x-5 flex-col">
<div className="flex gap-x-5">
<span
className={`inline-block p-2 rounded-lg bg-red-50 text-red-500`}
>
<Warning className="h-4 w-4 mb-1 inline-block" /> Could not
respond to message.
<span className="text-xs">Reason: {error || "unknown"}</span>
</span>
</div>
</div>
</div>
);
}
return (
<div
key={uuid}
ref={ref}
className={`flex justify-center items-end w-full ${embedderSettings.AI_BACKGROUND_COLOR}`}
>
<div
style={{ wordBreak: "break-word" }}
className="py-2 px-2 w-full flex flex-col"
>
<div className="flex gap-x-5">
<span
className={`reply whitespace-pre-line text-white font-normal text-sm md:text-sm flex flex-col gap-y-1 mt-2`}
dangerouslySetInnerHTML={{ __html: renderMarkdown(reply) }}
/>
</div>
</div>
</div>
);
}
);
export default memo(PromptReply);

View File

@ -0,0 +1,123 @@
import HistoricalMessage from "./HistoricalMessage";
import PromptReply from "./PromptReply";
import { useEffect, useRef, useState } from "react";
import { ArrowDown, CircleNotch } from "@phosphor-icons/react";
import debounce from "lodash.debounce";
export default function ChatHistory({ settings = {}, history = [] }) {
const replyRef = useRef(null);
const [isAtBottom, setIsAtBottom] = useState(true);
const chatHistoryRef = useRef(null);
useEffect(() => {
scrollToBottom();
}, [history]);
const handleScroll = () => {
if (!chatHistoryRef.current) return;
const diff =
chatHistoryRef.current.scrollHeight -
chatHistoryRef.current.scrollTop -
chatHistoryRef.current.clientHeight;
// Fuzzy margin for what qualifies as "bottom". Stronger than straight comparison since that may change over time.
const isBottom = diff <= 40;
setIsAtBottom(isBottom);
};
const debouncedScroll = debounce(handleScroll, 100);
useEffect(() => {
function watchScrollEvent() {
if (!chatHistoryRef.current) return null;
const chatHistoryElement = chatHistoryRef.current;
if (!chatHistoryElement) return null;
chatHistoryElement.addEventListener("scroll", debouncedScroll);
}
watchScrollEvent();
}, []);
const scrollToBottom = () => {
if (chatHistoryRef.current) {
chatHistoryRef.current.scrollTo({
top: chatHistoryRef.current.scrollHeight,
behavior: "smooth",
});
}
};
if (history.length === 0) {
return (
<div className="h-full max-h-[82vh] pb-[100px] pt-[5px] bg-gray-100 rounded-lg px-2 h-full mt-2 gap-y-2 overflow-y-scroll flex flex-col justify-start no-scroll">
<div className="flex h-full flex-col items-center justify-center">
<p className="text-slate-400 text-sm font-base py-4 text-center">
{settings?.greeting ?? "Send a chat to get started!"}
</p>
</div>
</div>
);
}
return (
<div
className="h-full max-h-[82vh] pb-[100px] pt-[5px] bg-gray-100 rounded-lg px-2 h-full mt-2 gap-y-2 overflow-y-scroll flex flex-col justify-start no-scroll"
id="chat-history"
ref={chatHistoryRef}
>
{history.map((props, index) => {
const isLastMessage = index === history.length - 1;
const isLastBotReply =
index === history.length - 1 && props.role === "assistant";
if (isLastBotReply && props.animate) {
return (
<PromptReply
key={props.uuid}
ref={isLastMessage ? replyRef : null}
uuid={props.uuid}
reply={props.content}
pending={props.pending}
sources={props.sources}
error={props.error}
closed={props.closed}
/>
);
}
return (
<HistoricalMessage
key={index}
ref={isLastMessage ? replyRef : null}
message={props.content}
role={props.role}
sources={props.sources}
error={props.error}
/>
);
})}
{!isAtBottom && (
<div className="fixed bottom-[10rem] right-[3rem] z-50 cursor-pointer animate-pulse">
<div className="flex flex-col items-center">
<div className="p-1 rounded-full border border-white/10 bg-white/10 hover:bg-white/20 hover:text-white">
<ArrowDown
weight="bold"
className="text-white/60 w-5 h-5"
onClick={scrollToBottom}
/>
</div>
</div>
</div>
)}
</div>
);
}
export function ChatHistoryLoading() {
return (
<div className="h-full w-full relative">
<div className="h-full max-h-[82vh] pb-[100px] pt-[5px] bg-gray-100 rounded-lg px-2 h-full mt-2 gap-y-2 overflow-y-scroll flex flex-col justify-start no-scroll">
<div className="flex h-full flex-col items-center justify-center">
<CircleNotch size={14} className="text-slate-400 animate-spin" />
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,78 @@
import { CircleNotch, PaperPlaneRight } from "@phosphor-icons/react";
import React, { useState, useRef } from "react";
export default function PromptInput({
setttings,
message,
submit,
onChange,
inputDisabled,
buttonDisabled,
}) {
const formRef = useRef(null);
const [_, setFocused] = useState(false);
const handleSubmit = (e) => {
setFocused(false);
submit(e);
};
const captureEnter = (event) => {
if (event.keyCode == 13) {
if (!event.shiftKey) {
submit(event);
}
}
};
const adjustTextArea = (event) => {
const element = event.target;
element.style.height = "auto";
element.style.height =
event.target.value.length !== 0 ? element.scrollHeight + "px" : "auto";
};
return (
<div className="w-full absolute left-0 bottom-[5px] z-10 flex justify-center items-center">
<form
onSubmit={handleSubmit}
className="flex flex-col gap-y-1 rounded-t-lg w-full items-center justify-center"
>
<div className="flex items-center rounded-lg">
<div className="bg-white border border-white/50 rounded-2xl flex flex-col px-4 overflow-hidden">
<div className="flex items-center w-full">
<textarea
onKeyUp={adjustTextArea}
onKeyDown={captureEnter}
onChange={onChange}
required={true}
disabled={inputDisabled}
onFocus={() => setFocused(true)}
onBlur={(e) => {
setFocused(false);
adjustTextArea(e);
}}
value={message}
className="cursor-text max-h-[100px] text-[14px] mx-2 py-2 w-full text-black bg-transparent placeholder:text-slate-800/60 resize-none active:outline-none focus:outline-none flex-grow"
placeholder={"Send a message"}
/>
<button
ref={formRef}
type="submit"
disabled={buttonDisabled}
className="inline-flex justify-center rounded-2xl cursor-pointer text-black group ml-4"
>
{buttonDisabled ? (
<CircleNotch className="w-4 h-4 animate-spin" />
) : (
<PaperPlaneRight className="w-4 h-4 my-3" weight="fill" />
)}
<span className="sr-only">Send message</span>
</button>
</div>
</div>
</div>
</form>
</div>
);
}

View File

@ -0,0 +1,91 @@
import React, { useState, useEffect } from "react";
import ChatHistory from "./ChatHistory";
import PromptInput from "./PromptInput";
import handleChat from "@/utils/chat";
import ChatService from "@/models/chatService";
export default function ChatContainer({
sessionId,
settings,
knownHistory = [],
}) {
const [message, setMessage] = useState("");
const [loadingResponse, setLoadingResponse] = useState(false);
const [chatHistory, setChatHistory] = useState(knownHistory);
// Resync history if the ref to known history changes
// eg: cleared.
useEffect(() => {
if (knownHistory.length !== chatHistory.length)
setChatHistory([...knownHistory]);
}, [knownHistory]);
const handleMessageChange = (event) => {
setMessage(event.target.value);
};
const handleSubmit = async (event) => {
event.preventDefault();
if (!message || message === "") return false;
const prevChatHistory = [
...chatHistory,
{ content: message, role: "user" },
{
content: "",
role: "assistant",
pending: true,
userMessage: message,
animate: true,
},
];
setChatHistory(prevChatHistory);
setMessage("");
setLoadingResponse(true);
};
useEffect(() => {
async function fetchReply() {
const promptMessage =
chatHistory.length > 0 ? chatHistory[chatHistory.length - 1] : null;
const remHistory = chatHistory.length > 0 ? chatHistory.slice(0, -1) : [];
var _chatHistory = [...remHistory];
if (!promptMessage || !promptMessage?.userMessage) {
setLoadingResponse(false);
return false;
}
await ChatService.streamChat(
sessionId,
settings,
promptMessage.userMessage,
(chatResult) =>
handleChat(
chatResult,
setLoadingResponse,
setChatHistory,
remHistory,
_chatHistory
)
);
return;
}
loadingResponse === true && fetchReply();
}, [loadingResponse, chatHistory]);
return (
<div className="h-full w-full relative">
<ChatHistory settings={settings} history={chatHistory} />
<PromptInput
settings={settings}
message={message}
submit={handleSubmit}
onChange={handleMessageChange}
inputDisabled={loadingResponse}
buttonDisabled={loadingResponse}
/>
</div>
);
}

View File

@ -0,0 +1,92 @@
import AnythingLLMLogo from "@/assets/anything-llm-dark.png";
import ChatService from "@/models/chatService";
import {
DotsThreeOutlineVertical,
Envelope,
Lightning,
X,
} from "@phosphor-icons/react";
import { useState } from "react";
export default function ChatWindowHeader({
sessionId,
settings = {},
iconUrl = null,
closeChat,
setChatHistory,
}) {
const [showingOptions, setShowOptions] = useState(false);
const handleChatReset = async () => {
await ChatService.resetEmbedChatSession(settings, sessionId);
setChatHistory([]);
setShowOptions(false);
};
return (
<div className="flex justify-between items-center relative">
<img
style={{ maxWidth: 100, maxHeight: 20 }}
src={iconUrl ?? AnythingLLMLogo}
alt={iconUrl ? "Brand" : "AnythingLLM Logo"}
/>
<div className="flex gap-x-1 items-center">
{settings.loaded && (
<button
type="button"
onClick={() => setShowOptions(!showingOptions)}
className="hover:bg-gray-100 rounded-sm text-slate-800"
>
<DotsThreeOutlineVertical
size={18}
weight={!showingOptions ? "regular" : "fill"}
/>
</button>
)}
<button
type="button"
onClick={closeChat}
className="hover:bg-gray-100 rounded-sm text-slate-800"
>
<X size={18} />
</button>
</div>
<OptionsMenu
settings={settings}
showing={showingOptions}
resetChat={handleChatReset}
/>
</div>
);
}
function OptionsMenu({ settings, showing, resetChat }) {
if (!showing) return null;
return (
<div className="absolute z-10 bg-white flex flex-col gap-y-1 rounded-lg shadow-lg border border-gray-300 top-[23px] right-[20px] max-w-[150px]">
<button
onClick={resetChat}
className="flex items-center gap-x-1 hover:bg-gray-100 text-sm text-gray-700 p-2 rounded-lg"
>
<Lightning size={14} />
<p>Reset Chat</p>
</button>
<ContactSupport email={settings.supportEmail} />
</div>
);
}
function ContactSupport({ email = null }) {
if (!email) return null;
const subject = `Inquiry from ${window.location.origin}`;
return (
<a
href={`mailto:${email}?Subject=${encodeURIComponent(subject)}`}
className="flex items-center gap-x-1 hover:bg-gray-100 text-sm text-gray-700 p-2 rounded-lg"
>
<Envelope size={14} />
<p>Email support</p>
</a>
);
}

View File

@ -0,0 +1,89 @@
import ChatWindowHeader from "./Header";
import SessionId from "../SessionId";
import useChatHistory from "@/hooks/chat/useChatHistory";
import ChatContainer from "./ChatContainer";
import Sponsor from "../Sponsor";
import { ChatHistoryLoading } from "./ChatContainer/ChatHistory";
export default function ChatWindow({ closeChat, settings, sessionId }) {
const { chatHistory, setChatHistory, loading } = useChatHistory(
settings,
sessionId
);
if (loading) {
return (
<div className="flex flex-col h-full">
<ChatWindowHeader
sessionId={sessionId}
settings={settings}
iconUrl={settings.brandImageUrl}
closeChat={closeChat}
setChatHistory={setChatHistory}
/>
<ChatHistoryLoading />
<div className="pt-4 pb-2 h-fit gap-y-1">
<SessionId />
<Sponsor settings={settings} />
</div>
</div>
);
}
setEventDelegatorForCodeSnippets();
return (
<div className="flex flex-col h-full">
<ChatWindowHeader
sessionId={sessionId}
settings={settings}
iconUrl={settings.brandImageUrl}
closeChat={closeChat}
setChatHistory={setChatHistory}
/>
<ChatContainer
sessionId={sessionId}
settings={settings}
knownHistory={chatHistory}
/>
<div className="pt-4 pb-2 h-fit gap-y-1">
<SessionId />
<Sponsor settings={settings} />
</div>
</div>
);
}
// Enables us to safely markdown and sanitize all responses without risk of injection
// but still be able to attach a handler to copy code snippets on all elements
// that are code snippets.
function copyCodeSnippet(uuid) {
const target = document.querySelector(`[data-code="${uuid}"]`);
if (!target) return false;
const markdown =
target.parentElement?.parentElement?.querySelector(
"pre:first-of-type"
)?.innerText;
if (!markdown) return false;
window.navigator.clipboard.writeText(markdown);
target.classList.add("text-green-500");
const originalText = target.innerHTML;
target.innerText = "Copied!";
target.setAttribute("disabled", true);
setTimeout(() => {
target.classList.remove("text-green-500");
target.innerHTML = originalText;
target.removeAttribute("disabled");
}, 2500);
}
// Listens and hunts for all data-code-snippet clicks.
function setEventDelegatorForCodeSnippets() {
document?.addEventListener("click", function (e) {
const target = e.target.closest("[data-code-snippet]");
const uuidCode = target?.dataset?.code;
if (!uuidCode) return false;
copyCodeSnippet(uuidCode);
});
}

View File

@ -0,0 +1,171 @@
const hljsCss = `
pre code.hljs{display:block;overflow-x:auto;padding:1em}code.hljs{padding:3px 5px}/*!
Theme: GitHub Dark Dimmed
Description: Dark dimmed theme as seen on github.com
Author: github.com
Maintainer: @Hirse
Updated: 2021-05-15
Colors taken from GitHub's CSS
*/.hljs{color:#adbac7;background:#22272e}.hljs-doctag,.hljs-keyword,.hljs-meta .hljs-keyword,.hljs-template-tag,.hljs-template-variable,.hljs-type,.hljs-variable.language_{color:#f47067}.hljs-title,.hljs-title.class_,.hljs-title.class_.inherited__,.hljs-title.function_{color:#dcbdfb}.hljs-attr,.hljs-attribute,.hljs-literal,.hljs-meta,.hljs-number,.hljs-operator,.hljs-selector-attr,.hljs-selector-class,.hljs-selector-id,.hljs-variable{color:#6cb6ff}.hljs-meta .hljs-string,.hljs-regexp,.hljs-string{color:#96d0ff}.hljs-built_in,.hljs-symbol{color:#f69d50}.hljs-code,.hljs-comment,.hljs-formula{color:#768390}.hljs-name,.hljs-quote,.hljs-selector-pseudo,.hljs-selector-tag{color:#8ddb8c}.hljs-subst{color:#adbac7}.hljs-section{color:#316dca;font-weight:700}.hljs-bullet{color:#eac55f}.hljs-emphasis{color:#adbac7;font-style:italic}.hljs-strong{color:#adbac7;font-weight:700}.hljs-addition{color:#b4f1b4;background-color:#1b4721}.hljs-deletion{color:#ffd8d3;background-color:#78191b}
`;
const customCss = `
/**
* ==============================================
* Dot Falling
* ==============================================
*/
.dot-falling {
position: relative;
left: -9999px;
width: 10px;
height: 10px;
border-radius: 5px;
background-color: #eeeeee;
color: #5fa4fa;
box-shadow: 9999px 0 0 0 #eeeeee;
animation: dot-falling 1.5s infinite linear;
animation-delay: 0.1s;
}
.dot-falling::before,
.dot-falling::after {
content: "";
display: inline-block;
position: absolute;
top: 0;
}
.dot-falling::before {
width: 10px;
height: 10px;
border-radius: 5px;
background-color: #eeeeee;
color: #eeeeee;
animation: dot-falling-before 1.5s infinite linear;
animation-delay: 0s;
}
.dot-falling::after {
width: 10px;
height: 10px;
border-radius: 5px;
background-color: #eeeeee;
color: #eeeeee;
animation: dot-falling-after 1.5s infinite linear;
animation-delay: 0.2s;
}
@keyframes dot-falling {
0% {
box-shadow: 9999px -15px 0 0 rgba(152, 128, 255, 0);
}
25%,
50%,
75% {
box-shadow: 9999px 0 0 0 #eeeeee;
}
100% {
box-shadow: 9999px 15px 0 0 rgba(152, 128, 255, 0);
}
}
@keyframes dot-falling-before {
0% {
box-shadow: 9984px -15px 0 0 rgba(152, 128, 255, 0);
}
25%,
50%,
75% {
box-shadow: 9984px 0 0 0 #eeeeee;
}
100% {
box-shadow: 9984px 15px 0 0 rgba(152, 128, 255, 0);
}
}
@keyframes dot-falling-after {
0% {
box-shadow: 10014px -15px 0 0 rgba(152, 128, 255, 0);
}
25%,
50%,
75% {
box-shadow: 10014px 0 0 0 #eeeeee;
}
100% {
box-shadow: 10014px 15px 0 0 rgba(152, 128, 255, 0);
}
}
#chat-history::-webkit-scrollbar,
#chat-container::-webkit-scrollbar,
.no-scroll::-webkit-scrollbar {
display: none !important;
}
/* Hide scrollbar for IE, Edge and Firefox */
#chat-history,
#chat-container,
.no-scroll {
-ms-overflow-style: none !important;
/* IE and Edge */
scrollbar-width: none !important;
/* Firefox */
}
.animate-slow-pulse {
transform: scale(1);
animation: subtlePulse 20s infinite;
will-change: transform;
}
@keyframes subtlePulse {
0% {
transform: scale(1);
}
50% {
transform: scale(1.1);
}
100% {
transform: scale(1);
}
}
@keyframes subtleShift {
0% {
background-position: 0% 50%;
}
50% {
background-position: 100% 50%;
}
100% {
background-position: 0% 50%;
}
}
.bg-black-900 {
background: #141414;
}
`;
export default function Head() {
return (
<head>
<style>{hljsCss}</style>
<style>{customCss}</style>
</head>
);
}

View File

@ -0,0 +1,33 @@
import {
Plus,
ChatCircleDots,
Headset,
Binoculars,
MagnifyingGlass,
MagicWand,
} from "@phosphor-icons/react";
const CHAT_ICONS = {
plus: Plus,
chatBubble: ChatCircleDots,
support: Headset,
search2: Binoculars,
search: MagnifyingGlass,
magic: MagicWand,
};
export default function OpenButton({ settings, isOpen, toggleOpen }) {
if (isOpen) return null;
const ChatIcon = CHAT_ICONS.hasOwnProperty(settings?.chatIcon)
? CHAT_ICONS[settings.chatIcon]
: CHAT_ICONS.plus;
return (
<button
onClick={toggleOpen}
className={`flex items-center justify-center p-4 rounded-full bg-[${settings.buttonColor}] text-white text-2xl`}
aria-label="Toggle Menu"
>
<ChatIcon className="text-white" />
</button>
);
}

View File

@ -0,0 +1,10 @@
import useSessionId from "@/hooks/useSessionId";
export default function SessionId() {
const sessionId = useSessionId();
if (!sessionId) return null;
return (
<div className="text-xs text-gray-300 w-full text-center">{sessionId}</div>
);
}

View File

@ -0,0 +1,16 @@
export default function Sponsor({ settings }) {
if (!!settings.noSponsor) return null;
return (
<div className="flex w-full items-center justify-center">
<a
href={settings.sponsorLink ?? "#"}
target="_blank"
rel="noreferrer"
className="text-xs text-gray-300 hover:text-blue-300 hover:underline"
>
{settings.sponsorText}
</a>
</div>
);
}

View File

@ -0,0 +1,27 @@
import ChatService from "@/models/chatService";
import { useEffect, useState } from "react";
export default function useChatHistory(settings = null, sessionId = null) {
const [loading, setLoading] = useState(true);
const [messages, setMessages] = useState([]);
useEffect(() => {
async function fetchChatHistory() {
if (!sessionId || !settings) return;
try {
const formattedMessages = await ChatService.embedSessionHistory(
settings,
sessionId
);
setMessages(formattedMessages);
setLoading(false);
} catch (error) {
console.error("Error fetching historical chats:", error);
setLoading(false);
}
}
fetchChatHistory();
}, [sessionId, settings]);
return { chatHistory: messages, setChatHistory: setMessages, loading };
}

View File

@ -0,0 +1,16 @@
import { CHAT_UI_REOPEN } from "@/utils/constants";
import { useState } from "react";
export default function useOpenChat() {
const [isOpen, setOpen] = useState(
!!window?.localStorage?.getItem(CHAT_UI_REOPEN) || false
);
function toggleOpenChat(newValue) {
if (newValue === true) window.localStorage.setItem(CHAT_UI_REOPEN, "1");
if (newValue === false) window.localStorage.removeItem(CHAT_UI_REOPEN);
setOpen(newValue);
}
return { isChatOpen: isOpen, toggleOpenChat };
}

View File

@ -0,0 +1,56 @@
import { useEffect, useState } from "react";
import { embedderSettings } from "../main";
const DEFAULT_SETTINGS = {
embedId: null, //required
baseApiUrl: null, // required
// Override properties that can be defined.
prompt: null, // override
model: null, // override
temperature: null, //override
// style parameters
chatIcon: "plus",
brandImageUrl: null, // will be forced into 100x50px container
greeting: null, // empty chat window greeting.
buttonColor: "#262626", // must be hex color code
userBgColor: "#2C2F35", // user text bubble color
assistantBgColor: "#2563eb", // assistant text bubble color
noSponsor: null, // Shows sponsor in footer of chat
sponsorText: "Powered by AnythingLLM", // default sponsor text
sponsorLink: "https://useanything.com", // default sponsor link
// behaviors
openOnLoad: "off", // or "on"
supportEmail: null, // string of email for contact
};
export default function useGetScriptAttributes() {
const [settings, setSettings] = useState({
loaded: false,
...DEFAULT_SETTINGS,
});
useEffect(() => {
function fetchAttribs() {
if (!document) return false;
if (
!embedderSettings.settings.baseApiUrl ||
!embedderSettings.settings.embedId
)
throw new Error(
"[AnythingLLM Embed Module::Abort] - Invalid script tag setup detected. Missing required parameters for boot!"
);
setSettings({
...DEFAULT_SETTINGS,
...embedderSettings.settings,
loaded: true,
});
}
fetchAttribs();
}, [document]);
return settings;
}

View File

@ -0,0 +1,29 @@
import { useEffect, useState } from "react";
import { embedderSettings } from "../main";
import { v4 } from "uuid";
export default function useSessionId() {
const [sessionId, setSessionId] = useState("");
useEffect(() => {
function getOrAssignSessionId() {
if (!window || !embedderSettings?.settings?.embedId) return;
const STORAGE_IDENTIFIER = `allm_${embedderSettings?.settings?.embedId}_session_id`;
const currentId = window.localStorage.getItem(STORAGE_IDENTIFIER);
if (!!currentId) {
console.log(`Resuming session id`, currentId);
setSessionId(currentId);
return;
}
const newId = v4();
console.log(`Registering new session id`, newId);
window.localStorage.setItem(STORAGE_IDENTIFIER, newId);
setSessionId(newId);
}
getOrAssignSessionId();
}, [window]);
return sessionId;
}

22
embed/src/main.jsx Normal file
View File

@ -0,0 +1,22 @@
import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App.jsx";
const appElement = document.createElement("div");
document.body.appendChild(appElement);
const root = ReactDOM.createRoot(appElement);
root.render(
<React.StrictMode>
<App />
</React.StrictMode>
);
const scriptSettings = Object.assign(
{},
document?.currentScript?.dataset || {}
);
export const embedderSettings = {
settings: scriptSettings,
USER_BACKGROUND_COLOR: `bg-[${scriptSettings?.userBgColor ?? "#2C2F35"}]`,
AI_BACKGROUND_COLOR: `bg-[${scriptSettings?.assistantBgColor ?? "#2563eb"}]`,
};

View File

@ -0,0 +1,108 @@
import { fetchEventSource } from "@microsoft/fetch-event-source";
import { v4 } from "uuid";
const ChatService = {
embedSessionHistory: async function (embedSettings, sessionId) {
const { embedId, baseApiUrl } = embedSettings;
return await fetch(`${baseApiUrl}/${embedId}/${sessionId}`)
.then((res) => {
if (res.ok) return res.json();
throw new Error("Invalid response from server");
})
.then((res) => {
return res.history.map((msg) => ({
...msg,
id: v4(),
sender: msg.role === "user" ? "user" : "system",
textResponse: msg.content,
close: false,
}));
})
.catch((e) => {
console.error(e);
return [];
});
},
resetEmbedChatSession: async function (embedSettings, sessionId) {
const { baseApiUrl, embedId } = embedSettings;
return await fetch(`${baseApiUrl}/${embedId}/${sessionId}`, {
method: "DELETE",
})
.then((res) => res.ok)
.catch(() => false);
},
streamChat: async function (sessionId, embedSettings, message, handleChat) {
const { baseApiUrl, embedId } = embedSettings;
const overrides = {
prompt: embedSettings?.prompt ?? null,
model: embedSettings?.model ?? null,
temperature: embedSettings?.temperature ?? null,
};
const ctrl = new AbortController();
await fetchEventSource(`${baseApiUrl}/${embedId}/stream-chat`, {
method: "POST",
body: JSON.stringify({
message,
sessionId,
...overrides,
}),
signal: ctrl.signal,
openWhenHidden: true,
async onopen(response) {
if (response.ok) {
return; // everything's good
} else if (response.status >= 400) {
await response
.json()
.then((serverResponse) => {
handleChat(serverResponse);
})
.catch(() => {
handleChat({
id: v4(),
type: "abort",
textResponse: null,
sources: [],
close: true,
error: `An error occurred while streaming response. Code ${response.status}`,
});
});
ctrl.abort();
throw new Error();
} else {
handleChat({
id: v4(),
type: "abort",
textResponse: null,
sources: [],
close: true,
error: `An error occurred while streaming response. Unknown Error.`,
});
ctrl.abort();
throw new Error("Unknown Error");
}
},
async onmessage(msg) {
try {
const chatResult = JSON.parse(msg.data);
handleChat(chatResult);
} catch {}
},
onerror(err) {
handleChat({
id: v4(),
type: "abort",
textResponse: null,
sources: [],
close: true,
error: `An error occurred while streaming response. ${err.message}`,
});
ctrl.abort();
throw new Error();
},
});
},
};
export default ChatService;

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,88 @@
/*
This is a dynamically generated file to help de-bloat the app since this script is a static bundle.
You should not modify this file directly. You can regenerate it with "node scripts/updateHljs.mjd" from the embed folder.
Last generated Fri Feb 02 2024
----------------------
*/
import hljs from "highlight.js/lib/core";
import apacheHljsSupport from 'highlight.js/lib/languages/apache'
import bashHljsSupport from 'highlight.js/lib/languages/bash'
import cHljsSupport from 'highlight.js/lib/languages/c'
import cppHljsSupport from 'highlight.js/lib/languages/cpp'
import csharpHljsSupport from 'highlight.js/lib/languages/csharp'
import cssHljsSupport from 'highlight.js/lib/languages/css'
import diffHljsSupport from 'highlight.js/lib/languages/diff'
import goHljsSupport from 'highlight.js/lib/languages/go'
import graphqlHljsSupport from 'highlight.js/lib/languages/graphql'
import iniHljsSupport from 'highlight.js/lib/languages/ini'
import javaHljsSupport from 'highlight.js/lib/languages/java'
import javascriptHljsSupport from 'highlight.js/lib/languages/javascript'
import jsonHljsSupport from 'highlight.js/lib/languages/json'
import kotlinHljsSupport from 'highlight.js/lib/languages/kotlin'
import lessHljsSupport from 'highlight.js/lib/languages/less'
import luaHljsSupport from 'highlight.js/lib/languages/lua'
import makefileHljsSupport from 'highlight.js/lib/languages/makefile'
import markdownHljsSupport from 'highlight.js/lib/languages/markdown'
import nginxHljsSupport from 'highlight.js/lib/languages/nginx'
import objectivecHljsSupport from 'highlight.js/lib/languages/objectivec'
import perlHljsSupport from 'highlight.js/lib/languages/perl'
import pgsqlHljsSupport from 'highlight.js/lib/languages/pgsql'
import phpHljsSupport from 'highlight.js/lib/languages/php'
import phptemplateHljsSupport from 'highlight.js/lib/languages/php-template'
import plaintextHljsSupport from 'highlight.js/lib/languages/plaintext'
import pythonHljsSupport from 'highlight.js/lib/languages/python'
import pythonreplHljsSupport from 'highlight.js/lib/languages/python-repl'
import rHljsSupport from 'highlight.js/lib/languages/r'
import rubyHljsSupport from 'highlight.js/lib/languages/ruby'
import rustHljsSupport from 'highlight.js/lib/languages/rust'
import scssHljsSupport from 'highlight.js/lib/languages/scss'
import shellHljsSupport from 'highlight.js/lib/languages/shell'
import sqlHljsSupport from 'highlight.js/lib/languages/sql'
import swiftHljsSupport from 'highlight.js/lib/languages/swift'
import typescriptHljsSupport from 'highlight.js/lib/languages/typescript'
import vbnetHljsSupport from 'highlight.js/lib/languages/vbnet'
import wasmHljsSupport from 'highlight.js/lib/languages/wasm'
import xmlHljsSupport from 'highlight.js/lib/languages/xml'
import yamlHljsSupport from 'highlight.js/lib/languages/yaml'
hljs.registerLanguage('apache', apacheHljsSupport)
hljs.registerLanguage('bash', bashHljsSupport)
hljs.registerLanguage('c', cHljsSupport)
hljs.registerLanguage('cpp', cppHljsSupport)
hljs.registerLanguage('csharp', csharpHljsSupport)
hljs.registerLanguage('css', cssHljsSupport)
hljs.registerLanguage('diff', diffHljsSupport)
hljs.registerLanguage('go', goHljsSupport)
hljs.registerLanguage('graphql', graphqlHljsSupport)
hljs.registerLanguage('ini', iniHljsSupport)
hljs.registerLanguage('java', javaHljsSupport)
hljs.registerLanguage('javascript', javascriptHljsSupport)
hljs.registerLanguage('json', jsonHljsSupport)
hljs.registerLanguage('kotlin', kotlinHljsSupport)
hljs.registerLanguage('less', lessHljsSupport)
hljs.registerLanguage('lua', luaHljsSupport)
hljs.registerLanguage('makefile', makefileHljsSupport)
hljs.registerLanguage('markdown', markdownHljsSupport)
hljs.registerLanguage('nginx', nginxHljsSupport)
hljs.registerLanguage('objectivec', objectivecHljsSupport)
hljs.registerLanguage('perl', perlHljsSupport)
hljs.registerLanguage('pgsql', pgsqlHljsSupport)
hljs.registerLanguage('php', phpHljsSupport)
hljs.registerLanguage('php-template', phptemplateHljsSupport)
hljs.registerLanguage('plaintext', plaintextHljsSupport)
hljs.registerLanguage('python', pythonHljsSupport)
hljs.registerLanguage('python-repl', pythonreplHljsSupport)
hljs.registerLanguage('r', rHljsSupport)
hljs.registerLanguage('ruby', rubyHljsSupport)
hljs.registerLanguage('rust', rustHljsSupport)
hljs.registerLanguage('scss', scssHljsSupport)
hljs.registerLanguage('shell', shellHljsSupport)
hljs.registerLanguage('sql', sqlHljsSupport)
hljs.registerLanguage('swift', swiftHljsSupport)
hljs.registerLanguage('typescript', typescriptHljsSupport)
hljs.registerLanguage('vbnet', vbnetHljsSupport)
hljs.registerLanguage('wasm', wasmHljsSupport)
hljs.registerLanguage('xml', xmlHljsSupport)
hljs.registerLanguage('yaml', yamlHljsSupport)
// The above should now register on the languages we wish to support statically.
export const staticHljs = hljs;

View File

@ -0,0 +1,96 @@
// For handling of synchronous chats that are not utilizing streaming or chat requests.
export default function handleChat(
chatResult,
setLoadingResponse,
setChatHistory,
remHistory,
_chatHistory
) {
const { uuid, textResponse, type, sources = [], error, close } = chatResult;
if (type === "abort") {
setLoadingResponse(false);
setChatHistory([
...remHistory,
{
uuid,
content: textResponse,
role: "assistant",
sources,
closed: true,
error,
animate: false,
pending: false,
},
]);
_chatHistory.push({
uuid,
content: textResponse,
role: "assistant",
sources,
closed: true,
error,
animate: false,
pending: false,
});
} else if (type === "textResponse") {
setLoadingResponse(false);
setChatHistory([
...remHistory,
{
uuid,
content: textResponse,
role: "assistant",
sources,
closed: close,
error,
animate: !close,
pending: false,
},
]);
_chatHistory.push({
uuid,
content: textResponse,
role: "assistant",
sources,
closed: close,
error,
animate: !close,
pending: false,
});
} else if (type === "textResponseChunk") {
const chatIdx = _chatHistory.findIndex((chat) => chat.uuid === uuid);
if (chatIdx !== -1) {
const existingHistory = { ..._chatHistory[chatIdx] };
const updatedHistory = {
...existingHistory,
content: existingHistory.content + textResponse,
sources,
error,
closed: close,
animate: !close,
pending: false,
};
_chatHistory[chatIdx] = updatedHistory;
} else {
_chatHistory.push({
uuid,
sources,
error,
content: textResponse,
role: "assistant",
closed: close,
animate: !close,
pending: false,
});
}
setChatHistory([..._chatHistory]);
}
}
export function chatPrompt(workspace) {
return (
workspace?.openAiPrompt ??
"Given the following conversation, relevant context, and a follow up question, reply with an answer to the current question the user is asking. Return only your response to the question given the above information following the users instructions as needed."
);
}

View File

@ -0,0 +1,47 @@
import { encode as HTMLEncode } from "he";
import markdownIt from "markdown-it";
import { staticHljs as hljs } from "./hljs";
import { v4 } from "uuid";
const markdown = markdownIt({
html: true,
typographer: true,
highlight: function (code, lang) {
const uuid = v4();
if (lang && hljs.getLanguage(lang)) {
try {
return (
`<div class="whitespace-pre-line w-full rounded-lg bg-black-900 pb-4 relative font-mono font-normal text-sm text-slate-200">
<div class="w-full flex items-center absolute top-0 left-0 text-slate-200 bg-stone-800 px-4 py-2 text-xs font-sans justify-between rounded-t-md">
<div class="flex gap-2"><code class="text-xs">${lang}</code></div>
<button data-code-snippet data-code="code-${uuid}" class="flex items-center gap-x-2">
<svg stroke="currentColor" fill="none" stroke-width="2" viewBox="0 0 24 24" stroke-linecap="round" stroke-linejoin="round" class="h-4 w-4" height="1em" width="1em" xmlns="http://www.w3.org/2000/svg"><path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2"></path><rect x="8" y="2" width="8" height="4" rx="1" ry="1"></rect></svg>
<p>Copy</p>
</button>
</div>
<pre class="whitespace-pre-wrap px-2">` +
hljs.highlight(code, { language: lang, ignoreIllegals: true }).value +
"</pre></div>"
);
} catch (__) {}
}
return (
`<div class="whitespace-pre-line w-full rounded-lg bg-black-900 pb-4 relative font-mono font-normal text-sm text-slate-200">
<div class="w-full flex items-center absolute top-0 left-0 text-slate-200 bg-stone-800 px-4 py-2 text-xs font-sans justify-between rounded-t-md">
<div class="flex gap-2"><code class="text-xs"></code></div>
<button data-code-snippet data-code="code-${uuid}" class="flex items-center gap-x-2">
<svg stroke="currentColor" fill="none" stroke-width="2" viewBox="0 0 24 24" stroke-linecap="round" stroke-linejoin="round" class="h-4 w-4" height="1em" width="1em" xmlns="http://www.w3.org/2000/svg"><path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2"></path><rect x="8" y="2" width="8" height="4" rx="1" ry="1"></rect></svg>
<p>Copy</p>
</button>
</div>
<pre class="whitespace-pre-wrap px-2">` +
HTMLEncode(code) +
"</pre></div>"
);
},
});
export default function renderMarkdown(text = "") {
return markdown.render(text);
}

View File

@ -0,0 +1 @@
export const CHAT_UI_REOPEN = "___anythingllm-chat-widget-open___";

64
embed/vite.config.js Normal file
View File

@ -0,0 +1,64 @@
// vite.config.js
import { defineConfig } from "vite"
import { fileURLToPath, URL } from "url"
import react from "@vitejs/plugin-react"
import image from "@rollup/plugin-image"
export default defineConfig({
plugins: [react(), image()],
define: {
// In dev, we need to disable this, but in prod, we need to enable it
"process.env.NODE_ENV": JSON.stringify("production")
},
resolve: {
alias: [
{
find: "@",
replacement: fileURLToPath(new URL("./src", import.meta.url))
},
{
process: "process/browser",
stream: "stream-browserify",
zlib: "browserify-zlib",
util: "util",
find: /^~.+/,
replacement: (val) => {
return val.replace(/^~/, "")
}
}
]
},
build: {
lib: {
entry: "src/main.jsx",
name: "EmbeddedAnythingLLM",
formats: ["umd"],
fileName: (_format) => `anythingllm-chat-widget.js`
},
rollupOptions: {
external: [
// Reduces transformation time by 50% and we don't even use this variant, so we can ignore.
/@phosphor-icons\/react\/dist\/ssr/,
]
},
commonjsOptions: {
transformMixedEsModules: true
},
cssCodeSplit: false,
assetsInlineLimit: 100000000,
minify: "esbuild",
outDir: "dist",
emptyOutDir: true,
inlineDynamicImports: true,
assetsDir: "",
sourcemap: 'inline',
},
optimizeDeps: {
esbuildOptions: {
define: {
global: "globalThis"
},
plugins: []
}
},
})

3035
embed/yarn.lock Normal file

File diff suppressed because it is too large Load Diff

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/GeneralSettings/EmbedChats"));
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,80 @@
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 };
});
},
chats: async (offset = 0) => {
return await fetch(`${API_BASE}/embed/chats`, {
method: "POST",
headers: baseHeaders(),
body: JSON.stringify({ offset }),
})
.then((res) => res.json())
.catch((e) => {
console.error(e);
return [];
});
},
deleteChat: async (chatId) => {
return await fetch(`${API_BASE}/embed/chats/${chatId}`, {
method: "DELETE",
headers: baseHeaders(),
})
.then((res) => res.json())
.catch((e) => {
console.error(e);
return { success: false, error: e.message };
});
},
};
export default Embed;

View File

@ -0,0 +1,130 @@
import { useRef } from "react";
import truncate from "truncate";
import { X, Trash, LinkSimple } from "@phosphor-icons/react";
import ModalWrapper from "@/components/ModalWrapper";
import { useModal } from "@/hooks/useModal";
import paths from "@/utils/paths";
import Embed from "@/models/embed";
export default function ChatRow({ chat }) {
const rowRef = useRef(null);
const {
isOpen: isPromptOpen,
openModal: openPromptModal,
closeModal: closePromptModal,
} = useModal();
const {
isOpen: isResponseOpen,
openModal: openResponseModal,
closeModal: closeResponseModal,
} = useModal();
const handleDelete = async () => {
if (
!window.confirm(
`Are you sure you want to delete this chat?\n\nThis action is irreversible.`
)
)
return false;
rowRef?.current?.remove();
await Embed.deleteChat(chat.id);
};
return (
<>
<tr
ref={rowRef}
className="bg-transparent text-white text-opacity-80 text-sm font-medium"
>
<td className="px-6 py-4 font-medium whitespace-nowrap text-white">
<a
href={paths.settings.embedSetup()}
target="_blank"
rel="noreferrer"
className="text-white flex items-center hover:underline"
>
<LinkSimple className="mr-2 w-5 h-5" />{" "}
{chat.embed_config.workspace.name}
</a>
</td>
<td className="px-6 py-4 font-medium whitespace-nowrap text-white">
<div className="flex flex-col">
<p>{truncate(chat.session_id, 20)}</p>
<ConnectionDetails
connection_information={chat.connection_information}
/>
</div>
</td>
<td
onClick={openPromptModal}
className="px-6 py-4 border-transparent cursor-pointer transform transition-transform duration-200 hover:scale-105 hover:shadow-lg"
>
{truncate(chat.prompt, 40)}
</td>
<td
onClick={openResponseModal}
className="px-6 py-4 cursor-pointer transform transition-transform duration-200 hover:scale-105 hover:shadow-lg"
>
{truncate(JSON.parse(chat.response)?.text, 40)}
</td>
<td className="px-6 py-4">{chat.createdAt}</td>
<td className="px-6 py-4 flex items-center gap-x-6">
<button
onClick={handleDelete}
className="font-medium text-red-300 px-2 py-1 rounded-lg hover:bg-red-800 hover:bg-opacity-20"
>
<Trash className="h-5 w-5" />
</button>
</td>
</tr>
<ModalWrapper isOpen={isPromptOpen}>
<TextPreview text={chat.prompt} closeModal={closePromptModal} />
</ModalWrapper>
<ModalWrapper isOpen={isResponseOpen}>
<TextPreview
text={JSON.parse(chat.response)?.text}
closeModal={closeResponseModal}
/>
</ModalWrapper>
</>
);
}
const TextPreview = ({ text, closeModal }) => {
return (
<div className="relative w-full md: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-600">
<h3 className="text-xl font-semibold text-white">Viewing Text</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"
>
<X className="text-gray-300 text-lg" />
</button>
</div>
<div className="w-full p-6">
<pre className="w-full h-[200px] py-2 px-4 whitespace-pre-line overflow-auto rounded-lg bg-zinc-900 border border-gray-500 text-white text-sm">
{text}
</pre>
</div>
</div>
</div>
);
};
const ConnectionDetails = ({ connection_information }) => {
let details = {};
try {
details = JSON.parse(connection_information);
} catch {}
if (Object.keys(details).length === 0) return null;
return (
<>
{details.ip && <p className="text-xs text-slate-400">{details.ip}</p>}
{details.host && <p className="text-xs text-slate-400">{details.host}</p>}
</>
);
};

View File

@ -0,0 +1,124 @@
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 useQuery from "@/hooks/useQuery";
import ChatRow from "./ChatRow";
import Embed from "@/models/embed";
export default function EmbedChats() {
// TODO [FEAT]: Add export of embed chats
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">Embed Chats</p>
</div>
<p className="text-sm font-base text-white text-opacity-60">
These are all the recorded chats and messages from any embed that
you have published.
</p>
</div>
<ChatsContainer />
</div>
</div>
</div>
);
}
function ChatsContainer() {
const query = useQuery();
const [loading, setLoading] = useState(true);
const [chats, setChats] = useState([]);
const [offset, setOffset] = useState(Number(query.get("offset") || 0));
const [canNext, setCanNext] = useState(false);
const handlePrevious = () => {
setOffset(Math.max(offset - 1, 0));
};
const handleNext = () => {
setOffset(offset + 1);
};
useEffect(() => {
async function fetchChats() {
const { chats: _chats, hasPages = false } = await Embed.chats(offset);
setChats(_chats);
setCanNext(hasPages);
setLoading(false);
}
fetchChats();
}, [offset]);
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="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">
Embed
</th>
<th scope="col" className="px-6 py-3">
Sender
</th>
<th scope="col" className="px-6 py-3">
Message
</th>
<th scope="col" className="px-6 py-3">
Response
</th>
<th scope="col" className="px-6 py-3">
Sent At
</th>
<th scope="col" className="px-6 py-3 rounded-tr-lg">
{" "}
</th>
</tr>
</thead>
<tbody>
{!!chats &&
chats.map((chat) => <ChatRow key={chat.id} chat={chat} />)}
</tbody>
</table>
<div className="flex w-full justify-between items-center">
<button
onClick={handlePrevious}
className="px-4 py-2 rounded-lg border border-slate-200 text-slate-200 text-sm items-center flex gap-x-2 hover:bg-slate-200 hover:text-slate-800 disabled:invisible"
disabled={offset === 0}
>
{" "}
Previous Page
</button>
<button
onClick={handleNext}
className="px-4 py-2 rounded-lg border border-slate-200 text-slate-200 text-sm items-center flex gap-x-2 hover:bg-slate-200 hover:text-slate-800 disabled:invisible"
disabled={!canNext}
>
Next Page
</button>
</div>
</>
);
}

View File

@ -0,0 +1,123 @@
import React, { useState } from "react";
import { CheckCircle, CopySimple, X } from "@phosphor-icons/react";
import showToast from "@/utils/toast";
import hljs from "highlight.js";
import "highlight.js/styles/github-dark-dimmed.min.css";
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://github.com/Mintplex-Labs/anything-llm/tree/master/embed/README.md
-->
<script
data-embed-id="${embed.uuid}"
data-base-api-url="${serverHost}/api/embed"
src="${scriptHost}/embed/anythingllm-chat-widget.min.js">
</script>
<!-- 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>
<a
href="https://github.com/Mintplex-Labs/anything-llm/tree/master/embed/README.md"
target="_blank"
className="text-blue-300 hover:underline"
>
View all style and configuration options &rarr;
</a>
</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,328 @@
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="workspace_id"
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="workspace_id"
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
selected={defaultValue === workspace.id}
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

@ -10,7 +10,7 @@
"node": ">=18"
},
"scripts": {
"lint": "cd server && yarn lint && cd ../frontend && yarn lint && cd ../collector && yarn lint",
"lint": "cd server && yarn lint && cd ../frontend && yarn lint && cd ../embed && yarn lint && cd ../collector && yarn lint",
"setup": "cd server && yarn && cd ../collector && yarn && cd ../frontend && yarn && cd .. && yarn setup:envs && yarn prisma:setup && echo \"Please run yarn dev:server, yarn dev:collector, and yarn dev:frontend in separate terminal tabs.\"",
"setup:envs": "cp -n ./frontend/.env.example ./frontend/.env && cp -n ./server/.env.example ./server/.env.development && cp -n ./collector/.env.example ./collector/.env && cp -n ./docker/.env.example ./docker/.env && echo \"All ENV files copied!\n\"",
"dev:server": "cd server && yarn dev",
@ -20,7 +20,7 @@
"prisma:migrate": "cd server && npx prisma migrate dev --name init",
"prisma:seed": "cd server && npx prisma db seed",
"prisma:setup": "yarn prisma:generate && yarn prisma:migrate && yarn prisma:seed",
"prisma:reset": "cd server && npx prisma db push --force-reset",
"prisma:reset": "truncate -s 0 server/storage/anythingllm.db && yarn prisma:migrate",
"prod:server": "cd server && yarn start",
"prod:frontend": "cd frontend && yarn build",
"generate:cloudformation": "node cloud-deployments/aws/cloudformation/generate.mjs",

1
server/.gitignore vendored
View File

@ -10,6 +10,7 @@ storage/imports
!storage/documents/DOCUMENTS.md
logs/server.log
*.db
*.db-journal
storage/lancedb
public/

View File

@ -0,0 +1,101 @@
const { v4: uuidv4 } = require("uuid");
const { reqBody, multiUserMode } = require("../../utils/http");
const { Telemetry } = require("../../models/telemetry");
const { writeResponseChunk } = require("../../utils/chats/stream");
const { streamChatWithForEmbed } = require("../../utils/chats/embed");
const { convertToChatHistory } = require("../../utils/chats");
const { EmbedChats } = require("../../models/embedChats");
const {
validEmbedConfig,
canRespond,
setConnectionMeta,
} = require("../../utils/middleware/embedMiddleware");
function embeddedEndpoints(app) {
if (!app) return;
app.post(
"/embed/:embedId/stream-chat",
[validEmbedConfig, setConnectionMeta, canRespond],
async (request, response) => {
try {
const embed = response.locals.embedConfig;
const {
sessionId,
message,
// optional keys for override of defaults if enabled.
prompt = null,
model = null,
temperature = null,
} = reqBody(request);
response.setHeader("Cache-Control", "no-cache");
response.setHeader("Content-Type", "text/event-stream");
response.setHeader("Access-Control-Allow-Origin", "*");
response.setHeader("Connection", "keep-alive");
response.flushHeaders();
await streamChatWithForEmbed(response, embed, message, sessionId, {
prompt,
model,
temperature,
});
await Telemetry.sendTelemetry("embed_sent_chat", {
multiUserMode: multiUserMode(response),
LLMSelection: process.env.LLM_PROVIDER || "openai",
Embedder: process.env.EMBEDDING_ENGINE || "inherit",
VectorDbSelection: process.env.VECTOR_DB || "pinecone",
});
response.end();
} catch (e) {
console.error(e);
writeResponseChunk(response, {
id: uuidv4(),
type: "abort",
textResponse: null,
close: true,
error: e.message,
});
response.end();
}
}
);
app.get(
"/embed/:embedId/:sessionId",
[validEmbedConfig],
async (request, response) => {
try {
const { sessionId } = request.params;
const embed = response.locals.embedConfig;
const history = await EmbedChats.forEmbedByUser(embed.id, sessionId);
response.status(200).json({
history: convertToChatHistory(history),
});
} catch (e) {
console.log(e.message, e);
response.sendStatus(500).end();
}
}
);
app.delete(
"/embed/:embedId/:sessionId",
[validEmbedConfig],
async (request, response) => {
try {
const { sessionId } = request.params;
const embed = response.locals.embedConfig;
await EmbedChats.markHistoryInvalid(embed.id, sessionId);
response.status(200).end();
} catch (e) {
console.log(e.message, e);
response.sendStatus(500).end();
}
}
);
}
module.exports = { embeddedEndpoints };

View File

@ -0,0 +1,115 @@
const { EmbedChats } = require("../models/embedChats");
const { EmbedConfig } = require("../models/embedConfig");
const { reqBody, userFromSession } = require("../utils/http");
const { 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 (request, response) => {
try {
const { offset = 0, limit = 20 } = reqBody(request);
const embedChats = await EmbedChats.whereWithEmbedAndWorkspace(
{},
limit,
{ id: "desc" },
offset * limit
);
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();
}
}
);
app.delete(
"/embed/chats/:chatId",
[validatedRequest, flexUserRoleValid([ROLES.admin])],
async (request, response) => {
try {
const { chatId } = request.params;
await EmbedChats.delete({ id: Number(chatId) });
response.status(200).json({ success: true, error: null });
} catch (e) {
console.error(e);
response.sendStatus(500).end();
}
}
);
}
module.exports = { embedManagementEndpoints };

View File

@ -10,6 +10,8 @@ const { reqBody } = require("./utils/http");
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");
@ -38,9 +40,13 @@ workspaceEndpoints(apiRouter);
chatEndpoints(apiRouter);
adminEndpoints(apiRouter);
inviteEndpoints(apiRouter);
embedManagementEndpoints(apiRouter);
utilEndpoints(apiRouter);
developerEndpoints(app, apiRouter);
// Externally facing embedder endpoints
embeddedEndpoints(apiRouter);
apiRouter.post("/v/:command", async (request, response) => {
try {
const VectorDb = getVectorDbClass();

162
server/models/embedChats.js Normal file
View File

@ -0,0 +1,162 @@
const prisma = require("../utils/prisma");
const EmbedChats = {
new: async function ({
embedId,
prompt,
response = {},
connection_information = {},
sessionId,
}) {
try {
const chat = await prisma.embed_chats.create({
data: {
prompt,
embed_id: Number(embedId),
response: JSON.stringify(response),
connection_information: JSON.stringify(connection_information),
session_id: sessionId,
},
});
return { chat, message: null };
} catch (error) {
console.error(error.message);
return { chat: null, message: error.message };
}
},
forEmbedByUser: async function (
embedId = null,
sessionId = null,
limit = null,
orderBy = null
) {
if (!embedId || !sessionId) return [];
try {
const chats = await prisma.embed_chats.findMany({
where: {
embed_id: embedId,
session_id: sessionId,
include: true,
},
...(limit !== null ? { take: limit } : {}),
...(orderBy !== null ? { orderBy } : { orderBy: { id: "asc" } }),
});
return chats;
} catch (error) {
console.error(error.message);
return [];
}
},
markHistoryInvalid: async function (embedId = null, sessionId = null) {
if (!embedId || !sessionId) return [];
try {
await prisma.embed_chats.updateMany({
where: {
embed_id: embedId,
session_id: sessionId,
},
data: {
include: false,
},
});
return;
} catch (error) {
console.error(error.message);
}
},
get: async function (clause = {}, limit = null, orderBy = null) {
try {
const chat = await prisma.embed_chats.findFirst({
where: clause,
...(limit !== null ? { take: limit } : {}),
...(orderBy !== null ? { orderBy } : {}),
});
return chat || null;
} catch (error) {
console.error(error.message);
return null;
}
},
delete: async function (clause = {}) {
try {
await prisma.embed_chats.deleteMany({
where: clause,
});
return true;
} catch (error) {
console.error(error.message);
return false;
}
},
where: async function (
clause = {},
limit = null,
orderBy = null,
offset = null
) {
try {
const chats = await prisma.embed_chats.findMany({
where: clause,
...(limit !== null ? { take: limit } : {}),
...(offset !== null ? { skip: offset } : {}),
...(orderBy !== null ? { orderBy } : {}),
});
return chats;
} catch (error) {
console.error(error.message);
return [];
}
},
whereWithEmbedAndWorkspace: async function (
clause = {},
limit = null,
orderBy = null,
offset = null
) {
try {
const chats = await prisma.embed_chats.findMany({
where: clause,
include: {
embed_config: {
select: {
workspace: {
select: {
name: 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({
where: clause,
});
return count;
} catch (error) {
console.error(error.message);
return 0;
}
},
};
module.exports = { EmbedChats };

View File

@ -0,0 +1,239 @@
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",
"allow_prompt_override",
"max_chats_per_day",
"max_chats_per_session",
"chat_mode",
"workspace_id",
],
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.workspace_id) },
},
},
});
return { embed, message: null };
} catch (error) {
console.error(error.message);
return { embed: 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 = {}) {
try {
const embedConfig = await prisma.embed_configs.findFirst({
where: clause,
});
return embedConfig || null;
} catch (error) {
console.error(error.message);
return null;
}
},
getWithWorkspace: async function (clause = {}) {
try {
const embedConfig = await prisma.embed_configs.findFirst({
where: clause,
include: {
workspace: true,
},
});
return embedConfig || null;
} catch (error) {
console.error(error.message);
return null;
}
},
delete: async function (clause = {}) {
try {
await prisma.embed_configs.delete({
where: clause,
});
return true;
} catch (error) {
console.error(error.message);
return false;
}
},
where: async function (clause = {}, limit = null, orderBy = null) {
try {
const results = await prisma.embed_configs.findMany({
where: clause,
...(limit !== null ? { take: limit } : {}),
...(orderBy !== null ? { orderBy } : {}),
});
return results;
} catch (error) {
console.error(error.message);
return [];
}
},
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
parseAllowedHosts: function (embed) {
if (!embed.allowlist_domains) return null;
try {
return JSON.parse(embed.allowlist_domains);
} catch {
console.error(`Failed to parse allowlist_domains for Embed ${embed.id}!`);
return [];
}
},
};
const BOOLEAN_KEYS = [
"allow_model_override",
"allow_temperature_override",
"allow_prompt_override",
"enabled",
];
const NUMBER_KEYS = [
"max_chats_per_day",
"max_chats_per_session",
"workspace_id",
];
// 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

@ -0,0 +1,37 @@
-- CreateTable
CREATE TABLE "embed_configs" (
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
"uuid" TEXT NOT NULL,
"enabled" BOOLEAN NOT NULL DEFAULT false,
"chat_mode" TEXT NOT NULL DEFAULT 'query',
"allowlist_domains" TEXT,
"allow_model_override" BOOLEAN NOT NULL DEFAULT false,
"allow_temperature_override" BOOLEAN NOT NULL DEFAULT false,
"allow_prompt_override" BOOLEAN NOT NULL DEFAULT false,
"max_chats_per_day" INTEGER,
"max_chats_per_session" INTEGER,
"workspace_id" INTEGER NOT NULL,
"createdBy" INTEGER,
"usersId" INTEGER,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "embed_configs_workspace_id_fkey" FOREIGN KEY ("workspace_id") REFERENCES "workspaces" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "embed_configs_usersId_fkey" FOREIGN KEY ("usersId") REFERENCES "users" ("id") ON DELETE SET NULL ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "embed_chats" (
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
"prompt" TEXT NOT NULL,
"response" TEXT NOT NULL,
"session_id" TEXT NOT NULL,
"include" BOOLEAN NOT NULL DEFAULT true,
"connection_information" TEXT,
"embed_id" INTEGER NOT NULL,
"usersId" INTEGER,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "embed_chats_embed_id_fkey" FOREIGN KEY ("embed_id") REFERENCES "embed_configs" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "embed_chats_usersId_fkey" FOREIGN KEY ("usersId") REFERENCES "users" ("id") ON DELETE SET NULL ON UPDATE CASCADE
);
-- CreateIndex
CREATE UNIQUE INDEX "embed_configs_uuid_key" ON "embed_configs"("uuid");

View File

@ -64,6 +64,8 @@ model users {
lastUpdatedAt DateTime @default(now())
workspace_chats workspace_chats[]
workspace_users workspace_users[]
embed_configs embed_configs[]
embed_chats embed_chats[]
}
model document_vectors {
@ -97,6 +99,7 @@ model workspaces {
topN Int? @default(4)
workspace_users workspace_users[]
documents workspace_documents[]
embed_configs embed_configs[]
}
model workspace_chats {
@ -131,3 +134,37 @@ model cache_data {
createdAt DateTime @default(now())
lastUpdatedAt DateTime @default(now())
}
model embed_configs {
id Int @id @default(autoincrement())
uuid String @unique
enabled Boolean @default(false)
chat_mode String @default("query")
allowlist_domains String?
allow_model_override Boolean @default(false)
allow_temperature_override Boolean @default(false)
allow_prompt_override Boolean @default(false)
max_chats_per_day Int?
max_chats_per_session Int?
workspace_id Int
createdBy Int?
usersId Int?
createdAt DateTime @default(now())
workspace workspaces @relation(fields: [workspace_id], references: [id], onDelete: Cascade)
embed_chats embed_chats[]
users users? @relation(fields: [usersId], references: [id])
}
model embed_chats {
id Int @id @default(autoincrement())
prompt String
response String
session_id String
include Boolean @default(true)
connection_information String?
embed_id Int
usersId Int?
createdAt DateTime @default(now())
embed_config embed_configs @relation(fields: [embed_id], references: [id], onDelete: Cascade)
users users? @relation(fields: [usersId], references: [id])
}

249
server/utils/chats/embed.js Normal file
View File

@ -0,0 +1,249 @@
const { v4: uuidv4 } = require("uuid");
const { getVectorDbClass, getLLMProvider } = require("../helpers");
const { chatPrompt, convertToPromptHistory } = require(".");
const { writeResponseChunk, handleStreamResponses } = require("./stream");
const { EmbedChats } = require("../../models/embedChats");
async function streamChatWithForEmbed(
response,
/** @type {import("@prisma/client").embed_configs & {workspace?: import("@prisma/client").workspaces}} */
embed,
/** @type {String} */
message,
/** @type {String} */
sessionId,
{ promptOverride, modelOverride, temperatureOverride }
) {
const chatMode = embed.chat_mode;
const chatModel = embed.allow_model_override ? modelOverride : null;
// If there are overrides in request & they are permitted, override the default workspace ref information.
if (embed.allow_prompt_override)
embed.workspace.openAiPrompt = promptOverride;
if (embed.allow_temperature_override)
embed.workspace.openAiTemp = parseFloat(temperatureOverride);
const uuid = uuidv4();
const LLMConnector = getLLMProvider(chatModel ?? embed.workspace?.chatModel);
const VectorDb = getVectorDbClass();
const { safe, reasons = [] } = await LLMConnector.isSafe(message);
if (!safe) {
writeResponseChunk(response, {
id: uuid,
type: "abort",
textResponse: null,
sources: [],
close: true,
error: `This message was moderated and will not be allowed. Violations for ${reasons.join(
", "
)} found.`,
});
return;
}
const messageLimit = 20;
const hasVectorizedSpace = await VectorDb.hasNamespace(embed.workspace.slug);
const embeddingsCount = await VectorDb.namespaceCount(embed.workspace.slug);
if (!hasVectorizedSpace || embeddingsCount === 0) {
if (chatMode === "query") {
writeResponseChunk(response, {
id: uuid,
type: "textResponse",
textResponse:
"I do not have enough information to answer that. Try another question.",
sources: [],
close: true,
error: null,
});
return;
}
// If there are no embeddings - chat like a normal LLM chat interface.
return await streamEmptyEmbeddingChat({
response,
uuid,
sessionId,
message,
embed,
messageLimit,
LLMConnector,
});
}
let completeText;
const { rawHistory, chatHistory } = await recentEmbedChatHistory(
sessionId,
embed,
messageLimit,
chatMode
);
const {
contextTexts = [],
sources = [],
message: error,
} = await VectorDb.performSimilaritySearch({
namespace: embed.workspace.slug,
input: message,
LLMConnector,
similarityThreshold: embed.workspace?.similarityThreshold,
topN: embed.workspace?.topN,
});
// Failed similarity search.
if (!!error) {
writeResponseChunk(response, {
id: uuid,
type: "abort",
textResponse: null,
sources: [],
close: true,
error: "Failed to connect to vector database provider.",
});
return;
}
// If in query mode and no sources are found, do not
// let the LLM try to hallucinate a response or use general knowledge
if (chatMode === "query" && sources.length === 0) {
writeResponseChunk(response, {
id: uuid,
type: "textResponse",
textResponse:
"There is no relevant information in this workspace to answer your query.",
sources: [],
close: true,
error: null,
});
return;
}
// Compress message to ensure prompt passes token limit with room for response
// and build system messages based on inputs and history.
const messages = await LLMConnector.compressMessages(
{
systemPrompt: chatPrompt(embed.workspace),
userPrompt: message,
contextTexts,
chatHistory,
},
rawHistory
);
// If streaming is not explicitly enabled for connector
// we do regular waiting of a response and send a single chunk.
if (LLMConnector.streamingEnabled() !== true) {
console.log(
`\x1b[31m[STREAMING DISABLED]\x1b[0m Streaming is not available for ${LLMConnector.constructor.name}. Will use regular chat method.`
);
completeText = await LLMConnector.getChatCompletion(messages, {
temperature: embed.workspace?.openAiTemp ?? LLMConnector.defaultTemp,
});
writeResponseChunk(response, {
uuid,
sources: [],
type: "textResponseChunk",
textResponse: completeText,
close: true,
error: false,
});
} else {
const stream = await LLMConnector.streamGetChatCompletion(messages, {
temperature: embed.workspace?.openAiTemp ?? LLMConnector.defaultTemp,
});
completeText = await handleStreamResponses(response, stream, {
uuid,
sources: [],
});
}
await EmbedChats.new({
embedId: embed.id,
prompt: message,
response: { text: completeText, type: chatMode },
connection_information: response.locals.connection
? { ...response.locals.connection }
: {},
sessionId,
});
return;
}
// On query we don't return message history. All other chat modes and when chatting
// with no embeddings we return history.
async function recentEmbedChatHistory(
sessionId,
embed,
messageLimit = 20,
chatMode = null
) {
if (chatMode === "query") return [];
const rawHistory = (
await EmbedChats.forEmbedByUser(embed.id, sessionId, messageLimit, {
id: "desc",
})
).reverse();
return { rawHistory, chatHistory: convertToPromptHistory(rawHistory) };
}
async function streamEmptyEmbeddingChat({
response,
uuid,
sessionId,
message,
embed,
messageLimit,
LLMConnector,
}) {
let completeText;
const { rawHistory, chatHistory } = await recentEmbedChatHistory(
sessionId,
embed,
messageLimit
);
if (LLMConnector.streamingEnabled() !== true) {
console.log(
`\x1b[31m[STREAMING DISABLED]\x1b[0m Streaming is not available for ${LLMConnector.constructor.name}. Will use regular chat method.`
);
completeText = await LLMConnector.sendChat(
chatHistory,
message,
embed.workspace,
rawHistory
);
writeResponseChunk(response, {
uuid,
type: "textResponseChunk",
textResponse: completeText,
sources: [],
close: true,
error: false,
});
}
const stream = await LLMConnector.streamChat(
chatHistory,
message,
embed.workspace,
rawHistory
);
completeText = await handleStreamResponses(response, stream, {
uuid,
sources: [],
});
await EmbedChats.new({
embedId: embed.id,
prompt: message,
response: { text: completeText, type: "chat" },
connection_information: response.locals.connection
? { ...response.locals.connection }
: {},
sessionId,
});
return;
}
module.exports = {
streamChatWithForEmbed,
};

View File

@ -509,4 +509,5 @@ module.exports = {
VALID_CHAT_MODE,
streamChatWithWorkspace,
writeResponseChunk,
handleStreamResponses,
};

View File

@ -0,0 +1,151 @@
const { v4: uuidv4 } = require("uuid");
const { VALID_CHAT_MODE } = require("../chats/stream");
const { EmbedChats } = require("../../models/embedChats");
const { EmbedConfig } = require("../../models/embedConfig");
const { reqBody } = require("../http");
// Finds or Aborts request for a /:embedId/ url. This should always
// be the first middleware and the :embedID should be in the URL.
async function validEmbedConfig(request, response, next) {
const { embedId } = request.params;
const embed = await EmbedConfig.getWithWorkspace({ uuid: embedId });
if (!embed) {
response.sendStatus(404).end();
return;
}
response.locals.embedConfig = embed;
next();
}
function setConnectionMeta(request, response, next) {
response.locals.connection = {
host: request.headers?.origin,
ip: request?.ip,
};
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) {
response.sendStatus(404).end();
return;
}
// Block if disabled by admin.
if (!embed.enabled) {
response.status(503).json({
id: uuidv4(),
type: "abort",
textResponse: null,
sources: [],
close: true,
error:
"This chat has been disabled by the administrator - try again later.",
});
return;
}
// Check if requester hostname is in the valid allowlist of domains.
const host = request.headers.origin ?? "";
const allowedHosts = EmbedConfig.parseAllowedHosts(embed);
if (allowedHosts !== null && !allowedHosts.includes(host)) {
response.status(401).json({
id: uuidv4(),
type: "abort",
textResponse: null,
sources: [],
close: true,
error: "Invalid request.",
});
return;
}
const { sessionId, message } = reqBody(request);
if (!message?.length || !VALID_CHAT_MODE.includes(embed.chat_mode)) {
response.status(400).json({
id: uuidv4(),
type: "abort",
textResponse: null,
sources: [],
close: true,
error: !message?.length
? "Message is empty."
: `${embed.chat_mode} is not a valid mode.`,
});
return;
}
if (!isNaN(embed.max_chats_per_day) && Number(embed.max_chats_per_day) > 0) {
const dailyChatCount = await EmbedChats.count({
embed_id: embed.id,
createdAt: {
gte: new Date(new Date() - 24 * 60 * 60 * 1000),
},
});
if (dailyChatCount >= Number(embed.max_chats_per_day)) {
response.status(429).json({
id: uuidv4(),
type: "abort",
textResponse: null,
sources: [],
close: true,
error:
"The quota for this chat has been reached. Try again later or contact the site owner.",
});
return;
}
}
if (
!isNaN(embed.max_chats_per_session) &&
Number(embed.max_chats_per_session) > 0
) {
const dailySessionCount = await EmbedChats.count({
embed_id: embed.id,
session_id: sessionId,
createdAt: {
gte: new Date(new Date() - 24 * 60 * 60 * 1000),
},
});
if (dailySessionCount >= Number(embed.max_chats_per_session)) {
response.status(429).json({
id: uuidv4(),
type: "abort",
textResponse: null,
sources: [],
close: true,
error:
"Your quota for this chat has been reached. Try again later or contact the site owner.",
});
return;
}
}
next();
}
module.exports = {
setConnectionMeta,
validEmbedConfig,
validEmbedConfigId,
canRespond,
};