From 1846a99b93939588b8d8d2a4a01eebbd109d3261 Mon Sep 17 00:00:00 2001 From: Sean Hatfield Date: Mon, 5 Feb 2024 14:21:34 -0800 Subject: [PATCH] [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 --- .vscode/settings.json | 3 + README.md | 4 +- embed/.gitignore | 25 + embed/.prettierignore | 9 + embed/README.md | 90 + embed/index.html | 13 + embed/jsconfig.json | 12 + embed/package.json | 43 + embed/scripts/updateHljs.mjs | 35 + embed/src/App.jsx | 52 + embed/src/assets/anything-llm-dark.png | Bin 0 -> 8413 bytes .../HistoricalMessage/Actions/index.jsx | 43 + .../ChatHistory/HistoricalMessage/index.jsx | 60 + .../ChatHistory/PromptReply/index.jsx | 65 + .../ChatContainer/ChatHistory/index.jsx | 123 + .../ChatContainer/PromptInput/index.jsx | 78 + .../ChatWindow/ChatContainer/index.jsx | 91 + .../components/ChatWindow/Header/index.jsx | 92 + embed/src/components/ChatWindow/index.jsx | 89 + embed/src/components/Head.jsx | 171 + embed/src/components/OpenButton/index.jsx | 33 + embed/src/components/SessionId/index.jsx | 10 + embed/src/components/Sponsor/index.jsx | 16 + embed/src/hooks/chat/useChatHistory.js | 27 + embed/src/hooks/useOpen.js | 16 + embed/src/hooks/useScriptAttributes.js | 56 + embed/src/hooks/useSessionId.js | 29 + embed/src/main.jsx | 22 + embed/src/models/chatService.js | 108 + embed/src/static/tailwind@3.4.1.js | 209 ++ embed/src/utils/chat/hljs.js | 88 + embed/src/utils/chat/index.js | 96 + embed/src/utils/chat/markdown.js | 47 + embed/src/utils/constants.js | 1 + embed/vite.config.js | 64 + embed/yarn.lock | 3035 +++++++++++++++++ .../embed/anythingllm-chat-widget.min.js | 38 + frontend/src/App.jsx | 12 + .../src/components/SettingsSidebar/index.jsx | 80 +- frontend/src/models/embed.js | 80 + .../EmbedChats/ChatRow/index.jsx | 130 + .../GeneralSettings/EmbedChats/index.jsx | 124 + .../EmbedRow/CodeSnippetModal/index.jsx | 123 + .../EmbedRow/EditEmbedModal/index.jsx | 121 + .../EmbedConfigs/EmbedRow/index.jsx | 140 + .../EmbedConfigs/NewEmbedModal/index.jsx | 328 ++ .../GeneralSettings/EmbedConfigs/index.jsx | 103 + frontend/src/utils/paths.js | 6 + package.json | 4 +- server/.gitignore | 1 + server/endpoints/embed/index.js | 101 + server/endpoints/embedManagement.js | 115 + server/index.js | 6 + server/models/embedChats.js | 162 + server/models/embedConfig.js | 239 ++ .../20240202002020_init/migration.sql | 37 + server/prisma/schema.prisma | 37 + server/utils/chats/embed.js | 249 ++ server/utils/chats/stream.js | 1 + server/utils/middleware/embedMiddleware.js | 151 + 60 files changed, 7328 insertions(+), 15 deletions(-) create mode 100644 embed/.gitignore create mode 100644 embed/.prettierignore create mode 100644 embed/README.md create mode 100644 embed/index.html create mode 100644 embed/jsconfig.json create mode 100644 embed/package.json create mode 100644 embed/scripts/updateHljs.mjs create mode 100644 embed/src/App.jsx create mode 100644 embed/src/assets/anything-llm-dark.png create mode 100644 embed/src/components/ChatWindow/ChatContainer/ChatHistory/HistoricalMessage/Actions/index.jsx create mode 100644 embed/src/components/ChatWindow/ChatContainer/ChatHistory/HistoricalMessage/index.jsx create mode 100644 embed/src/components/ChatWindow/ChatContainer/ChatHistory/PromptReply/index.jsx create mode 100644 embed/src/components/ChatWindow/ChatContainer/ChatHistory/index.jsx create mode 100644 embed/src/components/ChatWindow/ChatContainer/PromptInput/index.jsx create mode 100644 embed/src/components/ChatWindow/ChatContainer/index.jsx create mode 100644 embed/src/components/ChatWindow/Header/index.jsx create mode 100644 embed/src/components/ChatWindow/index.jsx create mode 100644 embed/src/components/Head.jsx create mode 100644 embed/src/components/OpenButton/index.jsx create mode 100644 embed/src/components/SessionId/index.jsx create mode 100644 embed/src/components/Sponsor/index.jsx create mode 100644 embed/src/hooks/chat/useChatHistory.js create mode 100644 embed/src/hooks/useOpen.js create mode 100644 embed/src/hooks/useScriptAttributes.js create mode 100644 embed/src/hooks/useSessionId.js create mode 100644 embed/src/main.jsx create mode 100644 embed/src/models/chatService.js create mode 100644 embed/src/static/tailwind@3.4.1.js create mode 100644 embed/src/utils/chat/hljs.js create mode 100644 embed/src/utils/chat/index.js create mode 100644 embed/src/utils/chat/markdown.js create mode 100644 embed/src/utils/constants.js create mode 100644 embed/vite.config.js create mode 100644 embed/yarn.lock create mode 100644 frontend/public/embed/anythingllm-chat-widget.min.js create mode 100644 frontend/src/models/embed.js create mode 100644 frontend/src/pages/GeneralSettings/EmbedChats/ChatRow/index.jsx create mode 100644 frontend/src/pages/GeneralSettings/EmbedChats/index.jsx create mode 100644 frontend/src/pages/GeneralSettings/EmbedConfigs/EmbedRow/CodeSnippetModal/index.jsx create mode 100644 frontend/src/pages/GeneralSettings/EmbedConfigs/EmbedRow/EditEmbedModal/index.jsx create mode 100644 frontend/src/pages/GeneralSettings/EmbedConfigs/EmbedRow/index.jsx create mode 100644 frontend/src/pages/GeneralSettings/EmbedConfigs/NewEmbedModal/index.jsx create mode 100644 frontend/src/pages/GeneralSettings/EmbedConfigs/index.jsx create mode 100644 server/endpoints/embed/index.js create mode 100644 server/endpoints/embedManagement.js create mode 100644 server/models/embedChats.js create mode 100644 server/models/embedConfig.js create mode 100644 server/prisma/migrations/20240202002020_init/migration.sql create mode 100644 server/utils/chats/embed.js create mode 100644 server/utils/middleware/embedMiddleware.js diff --git a/.vscode/settings.json b/.vscode/settings.json index 14c396fb..ac8c9472 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,7 +1,10 @@ { "cSpell.words": [ + "anythingllm", "Astra", "Dockerized", + "Embeddable", + "hljs", "Langchain", "Milvus", "Ollama", diff --git a/README.md b/README.md index 86413395..04f123d2 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/embed/.gitignore b/embed/.gitignore new file mode 100644 index 00000000..4d3751d9 --- /dev/null +++ b/embed/.gitignore @@ -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? diff --git a/embed/.prettierignore b/embed/.prettierignore new file mode 100644 index 00000000..d90a3c08 --- /dev/null +++ b/embed/.prettierignore @@ -0,0 +1,9 @@ +# defaults +**/.git +**/.svn +**/.hg +**/node_modules + +**/dist +**/static/** +src/utils/chat/hljs.js diff --git a/embed/README.md b/embed/README.md new file mode 100644 index 00000000..503348e9 --- /dev/null +++ b/embed/README.md @@ -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 ` +``` + +### ` + +`; +} + +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 ( +
+
+ +

+ Have your workspace chat embed function like a help desk chat bottom + in the corner of your website. +

+ + View all style and configuration options → + +
+ +
+ ); +}; diff --git a/frontend/src/pages/GeneralSettings/EmbedConfigs/EmbedRow/EditEmbedModal/index.jsx b/frontend/src/pages/GeneralSettings/EmbedConfigs/EmbedRow/EditEmbedModal/index.jsx new file mode 100644 index 00000000..a1b15eef --- /dev/null +++ b/frontend/src/pages/GeneralSettings/EmbedConfigs/EmbedRow/EditEmbedModal/index.jsx @@ -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 ( +
+
+
+

+ Update embed #{embed.id} +

+ +
+
+
+
+ + + + + + + + + + {error &&

Error: {error}

} +

+ After creating an embed you will be provided a link that you can + publish on your website with a simple + + <script> + {" "} + tag. +

+
+
+
+ + +
+
+
+
+ ); +} diff --git a/frontend/src/pages/GeneralSettings/EmbedConfigs/EmbedRow/index.jsx b/frontend/src/pages/GeneralSettings/EmbedConfigs/EmbedRow/index.jsx new file mode 100644 index 00000000..24c93991 --- /dev/null +++ b/frontend/src/pages/GeneralSettings/EmbedConfigs/EmbedRow/index.jsx @@ -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 ( + <> + + + + {embed.workspace.name} + + + + {nFormatter(embed._count.embed_chats)} + + + + + + + <> + + + + + + + + + + + + + + ); +} + +function ActiveDomains({ domainList }) { + if (!domainList) return

all

; + try { + const domains = JSON.parse(domainList); + return ( +
+ {domains.map((domain) => { + return

{domain}

; + })} +
+ ); + } catch { + return

all

; + } +} diff --git a/frontend/src/pages/GeneralSettings/EmbedConfigs/NewEmbedModal/index.jsx b/frontend/src/pages/GeneralSettings/EmbedConfigs/NewEmbedModal/index.jsx new file mode 100644 index 00000000..b1ea67ec --- /dev/null +++ b/frontend/src/pages/GeneralSettings/EmbedConfigs/NewEmbedModal/index.jsx @@ -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 ( +
+
+
+

+ Create new embed for workspace +

+ +
+
+
+
+ + + + + + + + + + {error &&

Error: {error}

} +

+ After creating an embed you will be provided a link that you can + publish on your website with a simple + + <script> + {" "} + tag. +

+
+
+
+ + +
+
+
+
+ ); +} + +export const WorkspaceSelection = ({ defaultValue = null }) => { + const [workspaces, setWorkspaces] = useState([]); + useEffect(() => { + async function fetchWorkspaces() { + const _workspaces = await Workspace.all(); + setWorkspaces(_workspaces); + } + fetchWorkspaces(); + }, []); + + return ( +
+
+ +

+ This is the workspace your chat window will be based on. All defaults + will be inherited from the workspace unless overridden by this config. +

+
+ +
+ ); +}; + +export const ChatModeSelection = ({ defaultValue = null }) => { + const [chatMode, setChatMode] = useState(defaultValue ?? "query"); + + return ( +
+
+ +

+ Set how your chatbot should operate. Query means it will only respond + if a document helps answer the query. +
+ Chat opens the chat to even general questions and can answer totally + unrelated queries to your workspace. +

+
+
+ + +
+
+ ); +}; + +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 ( +
+
+ +

+ This filter will block any requests that come from a domain other than + the list below. +
+ Leaving this empty means anyone can use your embed on any site. +

+
+ + +
+ ); +}; + +export const NumberInput = ({ name, title, hint, defaultValue = 0 }) => { + return ( +
+
+ +

{hint}

+
+ e.target.blur()} + /> +
+ ); +}; + +export const BooleanInput = ({ name, title, hint, defaultValue = null }) => { + const [status, setStatus] = useState(defaultValue ?? false); + + return ( +
+
+ +

{hint}

+
+