mirror of
https://github.com/Mintplex-Labs/anything-llm.git
synced 2024-11-05 06:20:10 +01:00
[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:
parent
146385bf41
commit
1846a99b93
3
.vscode/settings.json
vendored
3
.vscode/settings.json
vendored
@ -1,7 +1,10 @@
|
||||
{
|
||||
"cSpell.words": [
|
||||
"anythingllm",
|
||||
"Astra",
|
||||
"Dockerized",
|
||||
"Embeddable",
|
||||
"hljs",
|
||||
"Langchain",
|
||||
"Milvus",
|
||||
"Ollama",
|
||||
|
@ -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
25
embed/.gitignore
vendored
Normal 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
9
embed/.prettierignore
Normal file
@ -0,0 +1,9 @@
|
||||
# defaults
|
||||
**/.git
|
||||
**/.svn
|
||||
**/.hg
|
||||
**/node_modules
|
||||
|
||||
**/dist
|
||||
**/static/**
|
||||
src/utils/chat/hljs.js
|
90
embed/README.md
Normal file
90
embed/README.md
Normal 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
13
embed/index.html
Normal 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
12
embed/jsconfig.json
Normal file
@ -0,0 +1,12 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"module": "commonjs",
|
||||
"target": "esnext",
|
||||
"jsx": "react",
|
||||
"paths": {
|
||||
"@/*": [
|
||||
"./src/*"
|
||||
],
|
||||
}
|
||||
}
|
||||
}
|
43
embed/package.json
Normal file
43
embed/package.json
Normal 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"
|
||||
}
|
||||
}
|
35
embed/scripts/updateHljs.mjs
Normal file
35
embed/scripts/updateHljs.mjs
Normal 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
52
embed/src/App.jsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
BIN
embed/src/assets/anything-llm-dark.png
Normal file
BIN
embed/src/assets/anything-llm-dark.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 8.2 KiB |
@ -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);
|
@ -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);
|
@ -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);
|
@ -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>
|
||||
);
|
||||
}
|
@ -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>
|
||||
);
|
||||
}
|
91
embed/src/components/ChatWindow/ChatContainer/index.jsx
Normal file
91
embed/src/components/ChatWindow/ChatContainer/index.jsx
Normal 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>
|
||||
);
|
||||
}
|
92
embed/src/components/ChatWindow/Header/index.jsx
Normal file
92
embed/src/components/ChatWindow/Header/index.jsx
Normal 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>
|
||||
);
|
||||
}
|
89
embed/src/components/ChatWindow/index.jsx
Normal file
89
embed/src/components/ChatWindow/index.jsx
Normal 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);
|
||||
});
|
||||
}
|
171
embed/src/components/Head.jsx
Normal file
171
embed/src/components/Head.jsx
Normal 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>
|
||||
);
|
||||
}
|
33
embed/src/components/OpenButton/index.jsx
Normal file
33
embed/src/components/OpenButton/index.jsx
Normal 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>
|
||||
);
|
||||
}
|
10
embed/src/components/SessionId/index.jsx
Normal file
10
embed/src/components/SessionId/index.jsx
Normal 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>
|
||||
);
|
||||
}
|
16
embed/src/components/Sponsor/index.jsx
Normal file
16
embed/src/components/Sponsor/index.jsx
Normal 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>
|
||||
);
|
||||
}
|
27
embed/src/hooks/chat/useChatHistory.js
Normal file
27
embed/src/hooks/chat/useChatHistory.js
Normal 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 };
|
||||
}
|
16
embed/src/hooks/useOpen.js
Normal file
16
embed/src/hooks/useOpen.js
Normal 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 };
|
||||
}
|
56
embed/src/hooks/useScriptAttributes.js
Normal file
56
embed/src/hooks/useScriptAttributes.js
Normal 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;
|
||||
}
|
29
embed/src/hooks/useSessionId.js
Normal file
29
embed/src/hooks/useSessionId.js
Normal 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
22
embed/src/main.jsx
Normal 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"}]`,
|
||||
};
|
108
embed/src/models/chatService.js
Normal file
108
embed/src/models/chatService.js
Normal 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;
|
209
embed/src/static/tailwind@3.4.1.js
Normal file
209
embed/src/static/tailwind@3.4.1.js
Normal file
File diff suppressed because one or more lines are too long
88
embed/src/utils/chat/hljs.js
Normal file
88
embed/src/utils/chat/hljs.js
Normal 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;
|
96
embed/src/utils/chat/index.js
Normal file
96
embed/src/utils/chat/index.js
Normal 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."
|
||||
);
|
||||
}
|
47
embed/src/utils/chat/markdown.js
Normal file
47
embed/src/utils/chat/markdown.js
Normal 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);
|
||||
}
|
1
embed/src/utils/constants.js
Normal file
1
embed/src/utils/constants.js
Normal file
@ -0,0 +1 @@
|
||||
export const CHAT_UI_REOPEN = "___anythingllm-chat-widget-open___";
|
64
embed/vite.config.js
Normal file
64
embed/vite.config.js
Normal 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
3035
embed/yarn.lock
Normal file
File diff suppressed because it is too large
Load Diff
38
frontend/public/embed/anythingllm-chat-widget.min.js
vendored
Normal file
38
frontend/public/embed/anythingllm-chat-widget.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
@ -41,6 +41,10 @@ const DataConnectors = lazy(
|
||||
const DataConnectorSetup = lazy(
|
||||
() => import("@/pages/GeneralSettings/DataConnectors/Connectors")
|
||||
);
|
||||
const EmbedConfigSetup = lazy(
|
||||
() => import("@/pages/GeneralSettings/EmbedConfigs")
|
||||
);
|
||||
const EmbedChats = lazy(() => import("@/pages/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"
|
||||
|
@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
80
frontend/src/models/embed.js
Normal file
80
frontend/src/models/embed.js
Normal 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;
|
130
frontend/src/pages/GeneralSettings/EmbedChats/ChatRow/index.jsx
Normal file
130
frontend/src/pages/GeneralSettings/EmbedChats/ChatRow/index.jsx
Normal 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>}
|
||||
</>
|
||||
);
|
||||
};
|
124
frontend/src/pages/GeneralSettings/EmbedChats/index.jsx
Normal file
124
frontend/src/pages/GeneralSettings/EmbedChats/index.jsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
@ -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 →
|
||||
</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>
|
||||
);
|
||||
};
|
@ -0,0 +1,121 @@
|
||||
import React, { useState } from "react";
|
||||
import { X } from "@phosphor-icons/react";
|
||||
import {
|
||||
BooleanInput,
|
||||
ChatModeSelection,
|
||||
NumberInput,
|
||||
PermittedDomains,
|
||||
WorkspaceSelection,
|
||||
enforceSubmissionSchema,
|
||||
} from "../../NewEmbedModal";
|
||||
import Embed from "@/models/embed";
|
||||
import showToast from "@/utils/toast";
|
||||
|
||||
export default function EditEmbedModal({ embed, closeModal }) {
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
const handleUpdate = async (e) => {
|
||||
setError(null);
|
||||
e.preventDefault();
|
||||
const form = new FormData(e.target);
|
||||
const data = enforceSubmissionSchema(form);
|
||||
const { success, error } = await Embed.updateEmbed(embed.id, data);
|
||||
if (success) {
|
||||
showToast("Embed updated successfully.", "success", { clear: true });
|
||||
setTimeout(() => {
|
||||
window.location.reload();
|
||||
}, 800);
|
||||
}
|
||||
setError(error);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative max-w-2xl max-h-full">
|
||||
<div className="relative bg-main-gradient rounded-lg shadow">
|
||||
<div className="flex items-start justify-between p-4 border-b rounded-t border-gray-500/50">
|
||||
<h3 className="text-xl font-semibold text-white">
|
||||
Update embed #{embed.id}
|
||||
</h3>
|
||||
<button
|
||||
onClick={closeModal}
|
||||
type="button"
|
||||
className="transition-all duration-300 text-gray-400 bg-transparent hover:border-white/60 rounded-lg text-sm p-1.5 ml-auto inline-flex items-center bg-sidebar-button hover:bg-menu-item-selected-gradient hover:border-slate-100 hover:border-opacity-50 border-transparent border"
|
||||
data-modal-hide="staticModal"
|
||||
>
|
||||
<X className="text-gray-300 text-lg" />
|
||||
</button>
|
||||
</div>
|
||||
<form onSubmit={handleUpdate}>
|
||||
<div className="p-6 space-y-6 flex h-auto max-h-[80vh] w-full overflow-y-scroll">
|
||||
<div className="w-full flex flex-col gap-y-6">
|
||||
<WorkspaceSelection defaultValue={embed.workspace.id} />
|
||||
<ChatModeSelection defaultValue={embed.chat_mode} />
|
||||
<PermittedDomains
|
||||
defaultValue={
|
||||
embed.allowlist_domains
|
||||
? JSON.parse(embed.allowlist_domains)
|
||||
: []
|
||||
}
|
||||
/>
|
||||
<NumberInput
|
||||
name="max_chats_per_day"
|
||||
title="Max chats per day"
|
||||
hint="Limit the amount of chats this embedded chat can process in a 24 hour period. Zero is unlimited."
|
||||
defaultValue={embed.max_chats_per_day}
|
||||
/>
|
||||
<NumberInput
|
||||
name="max_chats_per_session"
|
||||
title="Max chats per session"
|
||||
hint="Limit the amount of chats a session user can send with this embed in a 24 hour period. Zero is unlimited."
|
||||
defaultValue={embed.max_chats_per_session}
|
||||
/>
|
||||
<BooleanInput
|
||||
name="allow_model_override"
|
||||
title="Enable dynamic model use"
|
||||
hint="Allow setting of the preferred LLM model to override the workspace default."
|
||||
defaultValue={embed.allow_model_override}
|
||||
/>
|
||||
<BooleanInput
|
||||
name="allow_temperature_override"
|
||||
title="Enable dynamic LLM temperature"
|
||||
hint="Allow setting of the LLM temperature to override the workspace default."
|
||||
defaultValue={embed.allow_temperature_override}
|
||||
/>
|
||||
<BooleanInput
|
||||
name="allow_prompt_override"
|
||||
title="Enable Prompt Override"
|
||||
hint="Allow setting of the system prompt to override the workspace default."
|
||||
defaultValue={embed.allow_prompt_override}
|
||||
/>
|
||||
|
||||
{error && <p className="text-red-400 text-sm">Error: {error}</p>}
|
||||
<p className="text-white text-xs md:text-sm pb-8">
|
||||
After creating an embed you will be provided a link that you can
|
||||
publish on your website with a simple
|
||||
<code className="bg-stone-800 text-white mx-1 px-1 rounded-sm">
|
||||
<script>
|
||||
</code>{" "}
|
||||
tag.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex w-full justify-between items-center p-6 space-x-2 border-t rounded-b border-gray-500/50">
|
||||
<button
|
||||
onClick={closeModal}
|
||||
type="button"
|
||||
className="px-4 py-2 rounded-lg text-white hover:bg-stone-900 transition-all duration-300"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="transition-all duration-300 border border-slate-200 px-4 py-2 rounded-lg text-white text-sm items-center flex gap-x-2 hover:bg-slate-200 hover:text-slate-800 focus:ring-gray-800"
|
||||
>
|
||||
Update embed
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
@ -0,0 +1,140 @@
|
||||
import { useRef, useState } from "react";
|
||||
import { DotsThreeOutline, LinkSimple } from "@phosphor-icons/react";
|
||||
import showToast from "@/utils/toast";
|
||||
import { useModal } from "@/hooks/useModal";
|
||||
import ModalWrapper from "@/components/ModalWrapper";
|
||||
import Embed from "@/models/embed";
|
||||
import paths from "@/utils/paths";
|
||||
import { nFormatter } from "@/utils/numbers";
|
||||
import EditEmbedModal from "./EditEmbedModal";
|
||||
import CodeSnippetModal from "./CodeSnippetModal";
|
||||
|
||||
export default function EmbedRow({ embed }) {
|
||||
const rowRef = useRef(null);
|
||||
const [enabled, setEnabled] = useState(Number(embed.enabled) === 1);
|
||||
const {
|
||||
isOpen: isSettingsOpen,
|
||||
openModal: openSettingsModal,
|
||||
closeModal: closeSettingsModal,
|
||||
} = useModal();
|
||||
const {
|
||||
isOpen: isSnippetOpen,
|
||||
openModal: openSnippetModal,
|
||||
closeModal: closeSnippetModal,
|
||||
} = useModal();
|
||||
|
||||
const handleSuspend = async () => {
|
||||
if (
|
||||
!window.confirm(
|
||||
`Are you sure you want to disabled this embed?\nOnce disabled the embed will no longer respond to any chat requests.`
|
||||
)
|
||||
)
|
||||
return false;
|
||||
|
||||
const { success, error } = await Embed.updateEmbed(embed.id, {
|
||||
enabled: !enabled,
|
||||
});
|
||||
if (!success) showToast(error, "error", { clear: true });
|
||||
if (success) {
|
||||
showToast(
|
||||
`Embed ${enabled ? "has been disabled" : "is active"}.`,
|
||||
"success",
|
||||
{ clear: true }
|
||||
);
|
||||
setEnabled(!enabled);
|
||||
}
|
||||
};
|
||||
const handleDelete = async () => {
|
||||
if (
|
||||
!window.confirm(
|
||||
`Are you sure you want to delete this embed?\nOnce deleted this embed will no longer respond to chats or be active.\n\nThis action is irreversible.`
|
||||
)
|
||||
)
|
||||
return false;
|
||||
const { success, error } = await Embed.deleteEmbed(embed.id);
|
||||
if (!success) showToast(error, "error", { clear: true });
|
||||
if (success) {
|
||||
rowRef?.current?.remove();
|
||||
showToast("Embed deleted from system.", "success", { clear: true });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<tr
|
||||
ref={rowRef}
|
||||
className="bg-transparent text-white text-opacity-80 text-sm"
|
||||
>
|
||||
<th
|
||||
scope="row"
|
||||
className="px-6 py-4 whitespace-nowrap flex item-center gap-x-1"
|
||||
>
|
||||
<a
|
||||
href={paths.workspace.chat(embed.workspace.slug)}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-white flex items-center hover:underline"
|
||||
>
|
||||
<LinkSimple className="mr-2 w-5 h-5" /> {embed.workspace.name}
|
||||
</a>
|
||||
</th>
|
||||
<th scope="row" className="px-6 py-4 whitespace-nowrap">
|
||||
{nFormatter(embed._count.embed_chats)}
|
||||
</th>
|
||||
<th scope="row" className="px-6 py-4 whitespace-nowrap">
|
||||
<ActiveDomains domainList={embed.allowlist_domains} />
|
||||
</th>
|
||||
<td className="px-6 py-4 flex items-center gap-x-6">
|
||||
<button
|
||||
onClick={openSettingsModal}
|
||||
className="font-medium text-white text-opacity-80 rounded-lg hover:text-white px-2 py-1 hover:text-opacity-60 hover:bg-white hover:bg-opacity-10"
|
||||
>
|
||||
<DotsThreeOutline weight="fill" className="h-5 w-5" />
|
||||
</button>
|
||||
<>
|
||||
<button
|
||||
onClick={openSnippetModal}
|
||||
className="font-medium text-blue-600 dark:text-blue-300 px-2 py-1 rounded-lg hover:bg-blue-50 hover:dark:bg-blue-800 hover:dark:bg-opacity-20"
|
||||
>
|
||||
Show Code
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSuspend}
|
||||
className="font-medium text-orange-600 dark:text-orange-300 px-2 py-1 rounded-lg hover:bg-orange-50 hover:dark:bg-orange-800 hover:dark:bg-opacity-20"
|
||||
>
|
||||
{enabled ? "Disable" : "Enable"}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleDelete}
|
||||
className="font-medium text-red-600 dark:text-red-300 px-2 py-1 rounded-lg hover:bg-red-50 hover:dark:bg-red-800 hover:dark:bg-opacity-20"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</>
|
||||
</td>
|
||||
</tr>
|
||||
<ModalWrapper isOpen={isSettingsOpen}>
|
||||
<EditEmbedModal embed={embed} closeModal={closeSettingsModal} />
|
||||
</ModalWrapper>
|
||||
<ModalWrapper isOpen={isSnippetOpen}>
|
||||
<CodeSnippetModal embed={embed} closeModal={closeSnippetModal} />
|
||||
</ModalWrapper>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function ActiveDomains({ domainList }) {
|
||||
if (!domainList) return <p>all</p>;
|
||||
try {
|
||||
const domains = JSON.parse(domainList);
|
||||
return (
|
||||
<div className="flex flex-col gap-y-2">
|
||||
{domains.map((domain) => {
|
||||
return <p className="font-mono !font-normal">{domain}</p>;
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
} catch {
|
||||
return <p>all</p>;
|
||||
}
|
||||
}
|
@ -0,0 +1,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">
|
||||
<script>
|
||||
</code>{" "}
|
||||
tag.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex w-full justify-between items-center p-6 space-x-2 border-t rounded-b border-gray-500/50">
|
||||
<button
|
||||
onClick={closeModal}
|
||||
type="button"
|
||||
className="px-4 py-2 rounded-lg text-white hover:bg-stone-900 transition-all duration-300"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="transition-all duration-300 border border-slate-200 px-4 py-2 rounded-lg text-white text-sm items-center flex gap-x-2 hover:bg-slate-200 hover:text-slate-800 focus:ring-gray-800"
|
||||
>
|
||||
Create embed
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const WorkspaceSelection = ({ defaultValue = null }) => {
|
||||
const [workspaces, setWorkspaces] = useState([]);
|
||||
useEffect(() => {
|
||||
async function fetchWorkspaces() {
|
||||
const _workspaces = await Workspace.all();
|
||||
setWorkspaces(_workspaces);
|
||||
}
|
||||
fetchWorkspaces();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex flex-col mb-2">
|
||||
<label
|
||||
htmlFor="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>
|
||||
);
|
||||
};
|
103
frontend/src/pages/GeneralSettings/EmbedConfigs/index.jsx
Normal file
103
frontend/src/pages/GeneralSettings/EmbedConfigs/index.jsx
Normal file
@ -0,0 +1,103 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import Sidebar, { SidebarMobileHeader } from "@/components/SettingsSidebar";
|
||||
import { isMobile } from "react-device-detect";
|
||||
import * as Skeleton from "react-loading-skeleton";
|
||||
import "react-loading-skeleton/dist/skeleton.css";
|
||||
import { CodeBlock } from "@phosphor-icons/react";
|
||||
import EmbedRow from "./EmbedRow";
|
||||
import NewEmbedModal from "./NewEmbedModal";
|
||||
import { useModal } from "@/hooks/useModal";
|
||||
import ModalWrapper from "@/components/ModalWrapper";
|
||||
import Embed from "@/models/embed";
|
||||
|
||||
export default function EmbedConfigs() {
|
||||
const { isOpen, openModal, closeModal } = useModal();
|
||||
return (
|
||||
<div className="w-screen h-screen overflow-hidden bg-sidebar flex">
|
||||
{!isMobile && <Sidebar />}
|
||||
<div
|
||||
style={{ height: isMobile ? "100%" : "calc(100% - 32px)" }}
|
||||
className="transition-all duration-500 relative md:ml-[2px] md:mr-[16px] md:my-[16px] md:rounded-[26px] bg-main-gradient w-full h-full overflow-y-scroll border-4 border-accent"
|
||||
>
|
||||
{isMobile && <SidebarMobileHeader />}
|
||||
<div className="flex flex-col w-full px-1 md:px-20 md:py-12 py-16">
|
||||
<div className="w-full flex flex-col gap-y-1 pb-6 border-white border-b-2 border-opacity-10">
|
||||
<div className="items-center flex gap-x-4">
|
||||
<p className="text-2xl font-semibold text-white">
|
||||
Embeddable Chat Widgets
|
||||
</p>
|
||||
<button
|
||||
onClick={openModal}
|
||||
className="border border-slate-200 px-4 py-1 rounded-lg text-slate-200 text-sm items-center flex gap-x-2 hover:bg-slate-200 hover:text-slate-800"
|
||||
>
|
||||
<CodeBlock className="h-4 w-4" /> Create embed
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-sm font-base text-white text-opacity-60">
|
||||
Embeddable chat widgets are public facing chat interfaces that are
|
||||
tied to a single workspace. These allow you to build workspaces
|
||||
that then you can publish to the world.
|
||||
</p>
|
||||
</div>
|
||||
<EmbedContainer />
|
||||
</div>
|
||||
<ModalWrapper isOpen={isOpen}>
|
||||
<NewEmbedModal closeModal={closeModal} />
|
||||
</ModalWrapper>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EmbedContainer() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [embeds, setEmbeds] = useState([]);
|
||||
useEffect(() => {
|
||||
async function fetchUsers() {
|
||||
const _embeds = await Embed.embeds();
|
||||
setEmbeds(_embeds);
|
||||
setLoading(false);
|
||||
}
|
||||
fetchUsers();
|
||||
}, []);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Skeleton.default
|
||||
height="80vh"
|
||||
width="100%"
|
||||
highlightColor="#3D4147"
|
||||
baseColor="#2C2F35"
|
||||
count={1}
|
||||
className="w-full p-4 rounded-b-2xl rounded-tr-2xl rounded-tl-sm mt-6"
|
||||
containerClassName="flex w-full"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<table className="md:w-3/4 w-full text-sm text-left rounded-lg mt-5">
|
||||
<thead className="text-white text-opacity-80 text-sm font-bold uppercase border-white border-b border-opacity-60">
|
||||
<tr>
|
||||
<th scope="col" className="px-6 py-3 rounded-tl-lg">
|
||||
Workspace
|
||||
</th>
|
||||
<th scope="col" className="px-6 py-3">
|
||||
Sent Chats
|
||||
</th>
|
||||
<th scope="col" className="px-6 py-3">
|
||||
Active Domains
|
||||
</th>
|
||||
<th scope="col" className="px-6 py-3 rounded-tr-lg">
|
||||
{" "}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{embeds.map((embed) => (
|
||||
<EmbedRow key={embed.id} embed={embed} />
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
}
|
@ -93,6 +93,12 @@ export default {
|
||||
apiKeys: () => {
|
||||
return "/settings/api-keys";
|
||||
},
|
||||
embedSetup: () => {
|
||||
return `/settings/embed-config`;
|
||||
},
|
||||
embedChats: () => {
|
||||
return `/settings/embed-chats`;
|
||||
},
|
||||
dataConnectors: {
|
||||
list: () => {
|
||||
return "/settings/data-connectors";
|
||||
|
@ -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
1
server/.gitignore
vendored
@ -10,6 +10,7 @@ storage/imports
|
||||
!storage/documents/DOCUMENTS.md
|
||||
logs/server.log
|
||||
*.db
|
||||
*.db-journal
|
||||
storage/lancedb
|
||||
public/
|
||||
|
||||
|
101
server/endpoints/embed/index.js
Normal file
101
server/endpoints/embed/index.js
Normal 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 };
|
115
server/endpoints/embedManagement.js
Normal file
115
server/endpoints/embedManagement.js
Normal 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 };
|
@ -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
162
server/models/embedChats.js
Normal 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 };
|
239
server/models/embedConfig.js
Normal file
239
server/models/embedConfig.js
Normal 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 };
|
37
server/prisma/migrations/20240202002020_init/migration.sql
Normal file
37
server/prisma/migrations/20240202002020_init/migration.sql
Normal 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");
|
@ -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
249
server/utils/chats/embed.js
Normal 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,
|
||||
};
|
@ -509,4 +509,5 @@ module.exports = {
|
||||
VALID_CHAT_MODE,
|
||||
streamChatWithWorkspace,
|
||||
writeResponseChunk,
|
||||
handleStreamResponses,
|
||||
};
|
||||
|
151
server/utils/middleware/embedMiddleware.js
Normal file
151
server/utils/middleware/embedMiddleware.js
Normal 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,
|
||||
};
|
Loading…
Reference in New Issue
Block a user