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}

+
+