From 2de665785d62dc5aae2e01303b23e8aa830b8631 Mon Sep 17 00:00:00 2001 From: dks333 Date: Sun, 26 Jul 2026 01:13:11 -0700 Subject: [PATCH 01/23] feat: assistant widget --- assistant-widget.js | 166 ++++++++++ assistant/widget.mdx | 151 +++++++++ docs.json | 1 + snippets/assistant-widget-playground.jsx | 380 +++++++++++++++++++++++ 4 files changed, 698 insertions(+) create mode 100644 assistant-widget.js create mode 100644 assistant/widget.mdx create mode 100644 snippets/assistant-widget-playground.jsx diff --git a/assistant-widget.js b/assistant-widget.js new file mode 100644 index 0000000000..3cd3ad3449 --- /dev/null +++ b/assistant-widget.js @@ -0,0 +1,166 @@ +const ASSISTANT_WIDGET_ID = "mint_widget_b6d4fae8-946b-4a5d-9f6b-f48948ab115b"; +const ASSISTANT_WIDGET_EMBED_URL = + "https://cdn.jsdelivr.net/npm/@mintlify/assistant-widget@0.0/dist/browser/embed.js"; +const ASSISTANT_WIDGET_PATH = "/assistant/widget"; + +let assistantInitialized = false; +let assistantLoaderPromise; +let assistantThemeObserver; +let assistantRouteTransition = Promise.resolve(); + +const reportAssistantError = (error) => { + console.error("Mintlify Assistant failed to initialize", error); +}; + +const notifyAssistantReady = () => { + window.dispatchEvent(new Event("mintlify-assistant-ready")); +}; + +const normalizePath = (path) => + path.length > 1 ? path.replace(/\/+$/, "") : path; + +const getCurrentDocsPath = () => + document.documentElement.dataset.currentPath || window.location.pathname; + +const isAssistantWidgetPage = () => + normalizePath(getCurrentDocsPath()) === ASSISTANT_WIDGET_PATH; + +const getDocsTheme = () => + document.documentElement.classList.contains("dark") ? "dark" : "light"; + +const syncAssistantTheme = (assistant) => + assistant.update({ appearance: { theme: getDocsTheme() } }); + +const observeDocsTheme = (assistant) => { + assistantThemeObserver?.disconnect(); + + let currentTheme = getDocsTheme(); + const observer = new MutationObserver(() => { + const nextTheme = getDocsTheme(); + if (nextTheme === currentTheme) return; + + currentTheme = nextTheme; + void syncAssistantTheme(assistant).catch(reportAssistantError); + }); + + observer.observe(document.documentElement, { + attributes: true, + attributeFilter: ["class"], + }); + assistantThemeObserver = observer; +}; + +const openAssistantWidget = async (assistant) => { + await assistant.open({ focus: false }); + observeDocsTheme(assistant); + notifyAssistantReady(); + void syncAssistantTheme(assistant).catch(reportAssistantError); +}; + +const initializeAssistantApi = async (assistant) => { + try { + await assistant.init({ + id: ASSISTANT_WIDGET_ID, + appearance: { + theme: getDocsTheme(), + side: "bottom", + align: "end", + }, + }); + } catch (error) { + const message = error && error.message ? error.message : ""; + if (!message.includes("requires a non-empty public Widget ID")) throw error; + + await assistant.init({ + widgetId: ASSISTANT_WIDGET_ID, + theme: getDocsTheme(), + }); + } +}; + +const loadAssistantApi = () => { + if (window.MintlifyAssistant) { + return Promise.resolve(window.MintlifyAssistant); + } + if (assistantLoaderPromise) return assistantLoaderPromise; + + assistantLoaderPromise = new Promise((resolve, reject) => { + const handleLoad = () => { + const assistant = window.MintlifyAssistant; + if (assistant) { + resolve(assistant); + return; + } + + reject(new Error("The Assistant loader did not install its browser API.")); + }; + const handleError = () => { + reject(new Error("The Assistant loader could not be loaded.")); + }; + const existingLoader = document.querySelector( + `script[src="${ASSISTANT_WIDGET_EMBED_URL}"]`, + ); + + if (existingLoader) { + existingLoader.addEventListener("load", handleLoad, { once: true }); + existingLoader.addEventListener("error", handleError, { once: true }); + return; + } + + const assistantLoader = document.createElement("script"); + assistantLoader.type = "module"; + assistantLoader.src = ASSISTANT_WIDGET_EMBED_URL; + assistantLoader.crossOrigin = "anonymous"; + assistantLoader.addEventListener("load", handleLoad, { once: true }); + assistantLoader.addEventListener("error", handleError, { once: true }); + document.head.append(assistantLoader); + }); + + return assistantLoaderPromise; +}; + +const destroyAssistantWidget = async () => { + assistantThemeObserver?.disconnect(); + assistantThemeObserver = undefined; + if (!assistantInitialized) return; + + assistantInitialized = false; + await window.MintlifyAssistant?.destroy(); +}; + +const initializeAssistantWidget = async () => { + if (assistantInitialized || !isAssistantWidgetPage()) return; + + const assistant = await loadAssistantApi(); + if (!isAssistantWidgetPage()) return; + + await initializeAssistantApi(assistant); + assistantInitialized = true; + + if (!isAssistantWidgetPage()) { + await destroyAssistantWidget(); + return; + } + + await openAssistantWidget(assistant); +}; + +const reconcileAssistantWidget = () => { + assistantRouteTransition = assistantRouteTransition + .then(async () => { + if (isAssistantWidgetPage()) { + await initializeAssistantWidget(); + } else { + await destroyAssistantWidget(); + } + }) + .catch(reportAssistantError); +}; + +const assistantRouteObserver = new MutationObserver(reconcileAssistantWidget); +assistantRouteObserver.observe(document.documentElement, { + attributes: true, + attributeFilter: ["data-current-path"], +}); +window.addEventListener("popstate", reconcileAssistantWidget); +reconcileAssistantWidget(); diff --git a/assistant/widget.mdx b/assistant/widget.mdx new file mode 100644 index 0000000000..589c89c2ad --- /dev/null +++ b/assistant/widget.mdx @@ -0,0 +1,151 @@ +--- +title: "Assistant Widget" +sidebarTitle: "Widget package" +description: "Install and configure the Mintlify Assistant Widget in any website or web application." +keywords: ["assistant widget", "chat widget", "embed", "JavaScript"] +--- + +import { AssistantWidgetPlayground } from "/snippets/assistant-widget-playground.jsx"; + +export const WidgetCodeBlock = ({ children, ...props }) => ( + {children} +); + +Use `@mintlify/assistant-widget` to add your Mintlify assistant to any website or web application. The hosted package owns its trigger and renders inside a closed Shadow DOM, preventing your application styles from changing the widget. + +The only required browser option is the public Widget ID from your deployment's **Settings > Widget** page. The enabled state, allowed origins, starter questions, attachments, and bot protection remain managed in the Mintlify dashboard. + + + Replace `YOUR_WIDGET_ID` in the generated code with the public Widget ID from + your deployment. + + +## Install and configure + +Use the playground to configure the presentation, visual options, and observer hooks. The installation block updates as you change each option. + + + +Module scripts are deferred and run in document order. Keep the hosted loader before the initialization block when installing the widget with HTML. + +## Use a custom trigger + +Await `init()` before calling other methods. You can keep the built-in trigger or open the configured presentation from any button in your application. + +```js +await window.MintlifyAssistant.init({ + id: "YOUR_WIDGET_ID", +}); + +document.querySelector("#help-button").addEventListener("click", () => { + void window.MintlifyAssistant.open({ + source: "help-button", + focus: true, + }); +}); +``` + +To open the widget and immediately send a question, call `ask()`: + +```js +await window.MintlifyAssistant.ask("How do I authenticate?", { + source: "authentication-guide", + open: true, + focus: true, +}); +``` + +The `source` value is included in event metadata and requests, which lets you distinguish built-in interactions from your custom entry points. + +## Update a mounted widget + +Use `update()` to change appearance, labels, or hooks without clearing the current conversation. Only the supplied fields change. + +```js +await window.MintlifyAssistant.update({ + appearance: { + theme: "dark", + accent: "#7c3aed", + }, + labels: { + title: "Docs copilot", + trigger: "Ask docs", + }, +}); +``` + +Pass `null` to restore a field or group to its default: + +```js +await window.MintlifyAssistant.update({ + appearance: { + accent: null, + }, + hooks: null, +}); +``` + +Changing `identity` starts a new conversation. Changing the Widget ID or API endpoint requires calling `destroy()` before a new `init()`. + +## Configuration reference + +### Appearance + +| Option | Values | Description | +| -------------------------- | -------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------- | +| `variant` | `widget`, `modal`, `panel` | Controls whether the assistant opens as an anchored popover, centered dialog, or responsive side panel. | +| `theme` | `light`, `dark`, `system` | Sets the widget color scheme. The default is `system`. | +| `accent` | CSS color | Sets the color of primary controls. | +| `radius` | CSS border radius | Sets the panel radius, such as `18px`. | +| `font` | CSS font family | Uses a font already loaded by your application. The default is bundled Inter. | +| `side` | `top`, `bottom`, `left`, `right`, `inline-start`, `inline-end` | Positions the built-in trigger on a screen edge. | +| `align` | `start`, `center`, `end` | Aligns the trigger along its selected edge. | +| `dismissOnInteractOutside` | boolean | Controls whether pointer or focus interactions outside close the assistant. | +| `logo` | URL or `{ light, dark }` | Replaces the default Mintlify mark. | +| `zIndex` | number | Changes the stacking order of the widget host. | + +Arbitrary CSS and neutral-palette overrides are not supported. The closed Shadow DOM protects both your application and the widget from cross-site style regressions. + +### Hooks + +```js +hooks: { + event(event) { + console.log(event.type, event.actor, event.source); + }, + error(error) { + console.error(error.code, error.retryable, error.status); + }, +} +``` + +The `event` hook receives lifecycle and interaction metadata for `init`, `open`, `close`, `ask`, `update`, `reset`, `navigate`, and `destroy`. Events do not include question text, identity, session, or CAPTCHA tokens. + +The `error` hook receives a stable `code`, a `retryable` boolean, and an optional HTTP `status`. Exceptions thrown by either hook do not interrupt the widget. + +## Browser API + +| Method | Description | +| ------------------------ | ---------------------------------------------------------------------------------- | +| `init(config)` | Loads and mounts the widget. This is the readiness promise for every other method. | +| `open(options)` | Opens the configured presentation. | +| `close()` | Closes the widget. | +| `ask(question, options)` | Opens the widget if requested and sends a question. | +| `update(config)` | Deep-patches mutable identity, appearance, label, and hook settings. | +| `reset()` | Starts a fresh conversation. | +| `destroy()` | Removes the widget and releases its browser resources. | + +Conversation snapshots remain private to the widget. Each method resolves to `void`. + +## Content Security Policy + +If your site uses a Content Security Policy, allow the origins required by your enabled widget features: + +- `script-src`: `https://cdn.jsdelivr.net`, `https://challenges.cloudflare.com`, and `https://js.hcaptcha.com` +- `connect-src`: `https://api.mintlify.com`, `https://challenges.cloudflare.com`, and `https://*.hcaptcha.com` +- `frame-src`: `https://challenges.cloudflare.com` and `https://*.hcaptcha.com` +- `style-src`: `https://cdn.jsdelivr.net` and the nonce passed to `init()` +- `font-src`: `https://cdn.jsdelivr.net` +- `img-src`: any origins used by `appearance.logo` + +A strict `script-src` policy must still authorize both the loader and initialization script. Passing `nonce` to `init()` propagates it only to resources the widget creates after initialization. diff --git a/docs.json b/docs.json index cc2689cbfb..eb2b1c4869 100644 --- a/docs.json +++ b/docs.json @@ -234,6 +234,7 @@ "pages": [ "assistant/configure", "assistant/customize", + "assistant/widget", "assistant/skills", "assistant/use" ] diff --git a/snippets/assistant-widget-playground.jsx b/snippets/assistant-widget-playground.jsx new file mode 100644 index 0000000000..16a9c2574e --- /dev/null +++ b/snippets/assistant-widget-playground.jsx @@ -0,0 +1,380 @@ +export const AssistantWidgetPlayground = ({ CodeBlockComponent }) => { + // Mintlify evaluates snippet exports independently, so shared values must stay in this scope. + const EXAMPLE_WIDGET_ID = "YOUR_WIDGET_ID"; + const EMBED_URL = + "https://cdn.jsdelivr.net/npm/@mintlify/assistant-widget@0.0/dist/browser/embed.js"; + const PRESENTATION_OPTIONS = [ + { value: "widget", label: "Widget" }, + { value: "modal", label: "Modal" }, + { value: "panel", label: "Panel" }, + ]; + const THEME_OPTIONS = [ + { value: "system", label: "System" }, + { value: "light", label: "Light" }, + { value: "dark", label: "Dark" }, + ]; + const SIDE_OPTIONS = [ + { value: "top", label: "Top" }, + { value: "bottom", label: "Bottom" }, + { value: "left", label: "Left" }, + { value: "right", label: "Right" }, + { value: "inline-start", label: "Inline start" }, + { value: "inline-end", label: "Inline end" }, + ]; + const ALIGN_OPTIONS = [ + { value: "start", label: "Start" }, + { value: "center", label: "Center" }, + { value: "end", label: "End" }, + ]; + const INSTALL_OPTIONS = [ + { value: "html", label: "HTML" }, + { value: "next", label: "Next.js" }, + ]; + + const [installTarget, setInstallTarget] = useState("html"); + const [variant, setVariant] = useState("widget"); + const [theme, setTheme] = useState("system"); + const [accent, setAccent] = useState("#16a34a"); + const [radius, setRadius] = useState(18); + const [side, setSide] = useState("bottom"); + const [align, setAlign] = useState("end"); + const [trackEvents, setTrackEvents] = useState(true); + const [reportErrors, setReportErrors] = useState(true); + + const classNames = (...classes) => classes.filter(Boolean).join(" "); + + const renderSegmentedControl = ({ + ariaLabel, + onChange, + options, + value, + }) => ( +
+ {options.map((option) => ( + + ))} +
+ ); + + const renderSelectField = ({ label, onChange, options, value }) => ( + + ); + + const renderToggleRow = ({ checked, description, label, onChange }) => ( + + ); + + const appearance = useMemo( + () => ({ + variant, + theme, + accent, + radius: `${radius}px`, + side, + align, + }), + [accent, align, radius, side, theme, variant], + ); + + const liveHooks = useMemo( + () => ({ + event: trackEvents + ? (event) => console.log("Assistant event", event) + : null, + error: reportErrors + ? (error) => console.error("Assistant error", error) + : null, + }), + [reportErrors, trackEvents], + ); + + const updateMountedWidget = useCallback(() => { + if (!window.MintlifyAssistant) return; + const liveTheme = + appearance.theme === "system" + ? document.documentElement.classList.contains("dark") + ? "dark" + : "light" + : appearance.theme; + + void window.MintlifyAssistant.update({ + appearance: { + ...appearance, + theme: liveTheme, + }, + hooks: liveHooks, + }).catch(() => {}); + }, [appearance, liveHooks]); + + useEffect(() => { + updateMountedWidget(); + window.addEventListener("mintlify-assistant-ready", updateMountedWidget); + return () => { + window.removeEventListener( + "mintlify-assistant-ready", + updateMountedWidget, + ); + }; + }, [updateMountedWidget]); + + const configLines = [ + "{", + ` id: '${EXAMPLE_WIDGET_ID}',`, + " appearance: {", + ` variant: '${variant}',`, + ` theme: '${theme}',`, + ` accent: '${accent}',`, + ` radius: '${radius}px',`, + ` side: '${side}',`, + ` align: '${align}',`, + " },", + ]; + + if (trackEvents || reportErrors) { + configLines.push(" hooks: {"); + if (trackEvents) { + configLines.push( + " event(event) {", + " console.log('Assistant event', event);", + " },", + ); + } + if (reportErrors) { + configLines.push( + " error(error) {", + " console.error('Assistant error', error.code, error);", + " },", + ); + } + configLines.push(" },"); + } + + configLines.push("}"); + + const configCode = configLines.join("\n"); + const indentedConfig = configCode.split("\n").join("\n "); + const htmlCode = ` +`; + const nextCode = `'use client'; + +import Script from 'next/script'; + +const ASSISTANT_CONFIG = ${configCode}; + +export const AssistantWidget = () => ( + + + + +
+ + Loading assistant preview... +
+ +`; const configLines = [ "{", @@ -251,141 +518,175 @@ export const AssistantWidget = () => ( const installCode = installTarget === "html" ? htmlCode : nextCode; return ( -
-
-
- Widget playground -
-

- Changes apply to the widget on this page. -

-
+
+
+
+
+
+ Widget playground +
+

+ Changes apply to the widget preview. +

+
-
-
-
- - Presentation - - {renderSegmentedControl({ - ariaLabel: "Presentation", - value: variant, - options: PRESENTATION_OPTIONS, - onChange: setVariant, - })} -
- -
- {renderSelectField({ - label: "Theme", - value: theme, - options: THEME_OPTIONS, - onChange: setTheme, - })} - -
-
-
-
-
-
- Install -
-
- Copy the generated setup for your stack. -
+ + {installCode} +
- {renderSegmentedControl({ - ariaLabel: "Installation target", - value: installTarget, - options: INSTALL_OPTIONS, - onChange: setInstallTarget, - })}
+ {children ?
{children}
: null} +
- +
- {installCode} - -
+