-
Notifications
You must be signed in to change notification settings - Fork 54
feat: integrate Flowise AI chatbot widget #550
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
774e74e
b593eb8
8c18cfa
4fa0156
a8eb467
bf17ce9
ccbd120
6d6e109
e49fd60
f6f7032
f7e90bd
46b4e1a
98b5651
7510f90
01e4ecc
9ab6dbe
20d22a0
a4a3d72
0d260ec
002b6c5
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -31,6 +31,18 @@ yarn start | |
|
|
||
| This command starts a local development server and opens up a browser window. Most changes are reflected live without having to restart the server. | ||
|
|
||
| ### Environment Variables | ||
|
|
||
| Set these in a `.env` file in the root directory. | ||
|
|
||
| The "Ask Rootstock AI" assistant (the floating chat bubble, the navbar button, and the code block buttons) is off unless **all three** of the following are set — any one of them missing hides it entirely: | ||
|
jonathansmirnoff marked this conversation as resolved.
|
||
|
|
||
| - `FLOWISE_CHATBOT_ENABLED`: set to `true` to enable the assistant. Defaults to disabled, so it must be set explicitly in every environment that should show it (including hosted previews and production). | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔵 Nitpick | 🎨 Rootstock.605-Word-list | Auto Fix not Applied |
||
| - `FLOWISE_API_HOST`: base URL of the **reverse proxy in front of Flowise** — `http://localhost` for the local `rootstock-chatbot` compose stack, or the public proxy origin in a deployment. Point this at the proxy, **not** at Flowise's own port: the proxy is what injects the API key server-side (so no key ever reaches the browser), allowlists the handful of endpoints the widget needs, and sends the CORS headers. Aiming it straight at Flowise bypasses all three — the browser then blocks the calls with `No 'Access-Control-Allow-Origin' header is present`, which surfaces as a chat that loads but can't send messages or feedback. The proxy also needs its `ALLOWED_ORIGIN` set to this site's exact origin, port included (`http://localhost:3000` for `yarn start`). | ||
|
jonathansmirnoff marked this conversation as resolved.
|
||
| - `FLOWISE_CHATFLOW_ID`: id of the chatflow to embed. | ||
|
|
||
| These are read at **build time** (Docusaurus is a static site generator), so changing any of them requires a rebuild — they are not runtime toggles. | ||
|
jonathansmirnoff marked this conversation as resolved.
|
||
|
|
||
| ## Usage | ||
|
|
||
| ### Production Build | ||
|
|
||
Large diffs are not rendered by default.
| Original file line number | Diff line number | Diff line change | |||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -48,6 +48,7 @@ | ||||||||||
| "docusaurus-markdown-source-plugin": "^2.0.1", | |||||||||||
| "docusaurus-plugin-mermaid-pan-zoom": "^1.7.0", | |||||||||||
| "docusaurus-plugin-sass": "^0.2.6", | |||||||||||
| "flowise-embed": "^3.1.6", | |||||||||||
| "jssha": "^3.3.1", | |||||||||||
| "katex": "^0.16.11", | |||||||||||
| "mermaid-diagram-pan-zoom": "^1.7.0", | |||||||||||
|
|
@@ -67,6 +68,16 @@ | ||||||||||
| "dotenv": "16.4.7", | |||||||||||
| "linkinator": "^7.5.3" | |||||||||||
| }, | |||||||||||
| "overrides": { | |||||||||||
| "seroval": "^1.6.2", | |||||||||||
| "solid-js": "^1.9.15", | |||||||||||
| "multer": "^2.2.0" | |||||||||||
| }, | |||||||||||
|
Comment on lines
+71
to
+75
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Not applying this one — the premise is incomplete, and acting on it would regress security. Yarn classic ignoring
So The drift risk you point at is genuine, though — two fields that must stay in sync by hand. The real fix is standardising on one package manager and deleting the other lockfile, which is out of scope for this PR. |
|||||||||||
| "resolutions": { | |||||||||||
| "seroval": "^1.6.2", | |||||||||||
| "solid-js": "^1.9.15", | |||||||||||
| "multer": "^2.2.0" | |||||||||||
| }, | |||||||||||
| "browserslist": { | |||||||||||
| "production": [ | |||||||||||
| ">0.5%", | |||||||||||
|
|
|||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,92 @@ | ||
| /** | ||
| * Shared helpers for driving flowise-embed 3.1.6's Shadow DOM. | ||
| * | ||
| * The widget exposes no public API beyond init/initFull/destroy/clearChat — | ||
| * there is no open() and no send() — so every entry point (the navbar | ||
| * "Ask Rootstock" button, the code-block "Ask Rootstock AI" button, and the | ||
| * theming logic) has to reach into its shadow root. Keeping the selectors and | ||
| * state checks here means that coupling — and the two gotchas below — live in | ||
| * one place instead of being re-derived (and re-broken) per call site. | ||
| */ | ||
|
|
||
| // flowise tags both of these with a `part` attribute, which makes them stable | ||
| // selectors instead of positional guesses: | ||
| // <button part="button"> → the floating bubble toggle | ||
| // <div part="bot"> → the chat window | ||
| // | ||
| // GOTCHA 1: "the first <button> in the shadow root" is NOT the toggle once the | ||
| // chat window exists — the window brings its own buttons (send, reset, thumbs), | ||
| // so a positional lookup can style or click the wrong control, or toggle the | ||
| // chat closed when the intent was to open it. | ||
| export const TOGGLE_SELECTOR = '[part="button"]'; | ||
| export const WINDOW_SELECTOR = '[part="bot"]'; | ||
|
|
||
| /** The widget's shadow root, or null if it hasn't mounted (or is disabled). */ | ||
| export function getShadowRoot() { | ||
| return document.querySelector('flowise-chatbot')?.shadowRoot ?? null; | ||
| } | ||
|
|
||
| /** The floating bubble toggle. */ | ||
| export function getToggle(root) { | ||
| return ( | ||
| root.querySelector(TOGGLE_SELECTOR) || | ||
| // Fallback if a future version drops the part attribute: the toggle is the | ||
| // only button that lives OUTSIDE the chat window. | ||
| Array.from(root.querySelectorAll('button')).find( | ||
| (b) => !b.closest(WINDOW_SELECTOR), | ||
| ) || | ||
| null | ||
| ); | ||
| } | ||
|
|
||
| // GOTCHA 2: flowise keeps the chat window MOUNTED after it has been opened once | ||
| // and merely hides it with `transform: scale(0)`. So the presence of the window | ||
| // (or of its <textarea>) is NOT an "open" signal — relying on that caused a | ||
| // "sends but the window never opens" bug, where text went into the hidden | ||
| // input. Measure the rendered size instead. | ||
| export function isChatOpen(root) { | ||
| const win = root.querySelector(WINDOW_SELECTOR); | ||
| if (!win) return false; | ||
| const r = win.getBoundingClientRect(); | ||
| return r.width > 50 && r.height > 50; | ||
| } | ||
|
|
||
| /** Poll until `getter` returns a truthy value or the timeout elapses. */ | ||
| export function waitFor(getter, timeout = 4000, interval = 60) { | ||
| return new Promise((resolve) => { | ||
| const start = performance.now(); | ||
| const tick = () => { | ||
| let val = null; | ||
| try { | ||
| val = getter(); | ||
| } catch (_) { | ||
| /* shadow DOM not ready yet */ | ||
| } | ||
| if (val) return resolve(val); | ||
| if (performance.now() - start >= timeout) return resolve(null); | ||
| window.setTimeout(tick, interval); | ||
| }; | ||
| tick(); | ||
| }); | ||
| } | ||
|
|
||
| /** | ||
| * Makes the chat window visible, and never closes it: the toggle flips state, | ||
| * so clicking it while the window is already open would close it. | ||
| * | ||
| * Waits for the toggle to exist first, since flowise-embed loads asynchronously | ||
| * (querying once and giving up caused an intermittent "doesn't open" bug). | ||
| * | ||
| * Returns `{ ok, opened }`. `opened` is false when the window was already open, | ||
| * which lets callers report an "open" event only when one actually happened. | ||
| */ | ||
| export async function ensureChatOpen(root) { | ||
| if (isChatOpen(root)) return { ok: true, opened: false }; | ||
|
|
||
| const toggle = await waitFor(() => getToggle(root)); | ||
| if (!toggle) return { ok: false, opened: false }; | ||
|
|
||
| toggle.click(); | ||
| const ok = Boolean(await waitFor(() => (isChatOpen(root) ? true : null))); | ||
| return { ok, opened: ok }; | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,52 @@ | ||
| // "Ask Rootstock" AI assistant trigger (Flowise) — outline pill with a brand | ||
| // badge. The pill is transparent with a subtle border so it sits alongside the | ||
| // other navbar controls (e.g. the language dropdown); the orange logo badge is | ||
| // the constant brand anchor. Everything flips with the theme via existing | ||
| // tokens: | ||
| // --bs-contrast → black (light) / white (dark) → label text | ||
| // --rsk-input-border-color → gray-200 (light) / gray-800 (dark) → pill border | ||
| // --bs-primary → orange #E68300 (light) / #FF9100 (dark) → badge | ||
| .ask-rootstock-btn { | ||
| display: inline-flex; | ||
| align-items: center; | ||
| gap: 8px; | ||
| // asymmetric: extra left inset so the badge clears the rounded left edge | ||
| padding: 4px 16px 4px 10px; | ||
| border: 1px solid var(--rsk-input-border-color); | ||
| border-radius: 40px; | ||
| cursor: pointer; | ||
| white-space: nowrap; | ||
| font-family: $font-family-base; | ||
| color: var(--bs-contrast); | ||
| background-color: transparent; | ||
| transition: opacity .15s ease; | ||
|
|
||
| &__icon { | ||
| display: inline-flex; | ||
| align-items: center; | ||
| justify-content: center; | ||
| flex-shrink: 0; | ||
| width: 28px; | ||
| height: 28px; | ||
| border-radius: 8px; | ||
| background-color: var(--bs-primary); | ||
| color: #fff; // logo uses fill="currentColor" → white on the orange badge | ||
|
|
||
| svg { | ||
| display: block; | ||
| width: 16px; | ||
| height: 18px; | ||
| } | ||
| } | ||
|
|
||
| &__label { | ||
| font-size: $f14; | ||
| font-weight: 600; | ||
| line-height: 1; | ||
| letter-spacing: -0.01em; | ||
| } | ||
|
|
||
| &:hover { | ||
| opacity: .85; | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,136 @@ | ||
| /** | ||
| * Sends a code block to the Rootstock AI Assistant (flowise-embed). | ||
| * | ||
| * flowise-embed 3.1.6 exposes no public "send message" API (only init / | ||
| * initFull / destroy / clearChat), so we drive its Shadow DOM directly. The | ||
| * selectors and open-state handling are shared with the navbar button and the | ||
| * theming logic — see /src/_utils/flowiseChat.js. | ||
| * | ||
| * The Rootstock assistant is a retrieval bot indexed over the devportal docs, | ||
| * so we DON'T need to ship the whole code block: a head+tail excerpt plus a | ||
| * locator (page path + nearest heading + language) is enough for it to pull | ||
| * the full snippet from its knowledge base. | ||
| */ | ||
|
|
||
| import { ensureChatOpen, getShadowRoot, waitFor } from '/src/_utils/flowiseChat'; | ||
|
|
||
| // Keep the visible message small. The bot recovers the full block via RAG. | ||
| const MAX_CODE_CHARS = 1500; | ||
|
|
||
| function rtrim(s) { | ||
| return s.replace(/\s+$/, ''); | ||
| } | ||
|
|
||
| // Truncate keeping the head AND tail — for code the signature/imports (top) | ||
| // and the closing (bottom) are the most identifiable parts, which gives both | ||
| // better retrieval and a better answer than a hard cut. The elided middle is | ||
| // filled back in by the bot's index. | ||
| function truncateCode(code) { | ||
| const trimmed = rtrim(code); | ||
| if (trimmed.length <= MAX_CODE_CHARS) { | ||
| return { text: trimmed, truncated: false }; | ||
| } | ||
| const headLen = Math.ceil(MAX_CODE_CHARS * 0.6); | ||
| const tailLen = MAX_CODE_CHARS - headLen; | ||
| const head = rtrim(trimmed.slice(0, headLen)); | ||
| const tail = trimmed.slice(-tailLen).replace(/^\s+/, ''); | ||
| return { text: `${head}\n\n… (truncated) …\n\n${tail}`, truncated: true }; | ||
| } | ||
|
|
||
| // Nearest preceding heading, to tell the bot which section the block is in. | ||
| // Walks up the ancestor chain and, at each level, scans previous siblings for | ||
| // a heading (deepest match wins). | ||
| export function findNearestHeading(startEl) { | ||
| const HEADING = 'h1, h2, h3, h4'; | ||
| let node = startEl; | ||
| while (node && node !== document.body) { | ||
| let sib = node.previousElementSibling; | ||
| while (sib) { | ||
| if (sib.matches && sib.matches(HEADING)) return cleanHeading(sib); | ||
| const inner = sib.querySelectorAll && sib.querySelectorAll(HEADING); | ||
| if (inner && inner.length) return cleanHeading(inner[inner.length - 1]); | ||
| sib = sib.previousElementSibling; | ||
| } | ||
| node = node.parentElement; | ||
| } | ||
| return null; | ||
| } | ||
|
|
||
| function cleanHeading(h) { | ||
| // Strip Docusaurus' hash-link artifacts (zero-width space / trailing #). | ||
| return h.textContent.replace(/[#]+$/g, '').trim(); | ||
| } | ||
|
|
||
| export function buildPrompt({ code, language, heading }) { | ||
| const lang = (language || '').toLowerCase(); | ||
| const { text, truncated } = truncateCode(code); | ||
|
|
||
| const page = typeof window !== 'undefined' ? window.location.pathname : ''; | ||
| const locatorParts = [ | ||
| page && `page: ${page}`, | ||
| heading && `section "${heading}"`, | ||
| ].filter(Boolean); | ||
| const locator = locatorParts.length ? ` (${locatorParts.join(', ')})` : ''; | ||
|
|
||
| const langLabel = lang ? `\`${lang}\` ` : ''; | ||
| const note = truncated | ||
| ? '\n(Excerpt truncated — the full snippet is on this docs page.)' | ||
| : ''; | ||
|
|
||
| return ( | ||
| `Explain this ${langLabel}code from the Rootstock docs${locator}:\n\n` + | ||
| `\`\`\`${lang}\n${text}\n\`\`\`${note}` | ||
| ); | ||
| } | ||
|
|
||
| function setNativeValue(el, value) { | ||
| const desc = Object.getOwnPropertyDescriptor( | ||
| Object.getPrototypeOf(el), | ||
| 'value', | ||
| ); | ||
| if (desc && desc.set) desc.set.call(el, value); | ||
| else el.value = value; | ||
| } | ||
|
|
||
| /** | ||
| * Opens the chat window and prefills the input with `message`, then focuses it. | ||
| * It deliberately does NOT submit — the user reviews the prefilled question and | ||
| * presses send themselves. Returns true on success, false if the widget isn't | ||
| * available. | ||
| */ | ||
| export async function openChatWithPrompt(message) { | ||
| const root = getShadowRoot(); | ||
| if (!root) return false; | ||
|
|
||
| if (!(await ensureChatOpen(root)).ok) return false; | ||
|
|
||
| const textarea = await waitFor(() => root.querySelector('textarea'), 5000); | ||
| if (!textarea) return false; | ||
|
|
||
| // CRITICAL: flowise (SolidJS) binds the input handler via event delegation on | ||
| // the top-level `document` (`textarea.$$input`), NOT on the element itself. | ||
| // A synthetic event therefore has to be `composed: true` to escape the | ||
| // widget's shadow root and reach that document-level listener — exactly what | ||
| // real keystrokes do (native `input` events are composed). Without it the | ||
| // input signal never updates and SolidJS wipes our text on the next render. | ||
| // Set the value first so the handler reads our text off currentTarget.value. | ||
| setNativeValue(textarea, message); | ||
| textarea.dispatchEvent( | ||
| new InputEvent('input', { | ||
| bubbles: true, | ||
| composed: true, | ||
| inputType: 'insertText', | ||
| data: message, | ||
| }), | ||
| ); | ||
|
|
||
| // Focus + caret at end so the user can edit and hit send. | ||
| textarea.focus(); | ||
| const end = textarea.value.length; | ||
| try { | ||
| textarea.setSelectionRange(end, end); | ||
| } catch (_) { | ||
| /* setSelectionRange can throw on some input types; ignore */ | ||
| } | ||
| return true; | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Review: README.md
Page:
README.mdSummary
No merge-blocking issues were identified in
README.md.The remaining 4 LOW-severity finding(s) are editorial (style guide, contractions, product naming, AI-style patterns). Those improvements are valuable but not merge blockers and can be addressed in a follow-up pass.
Fixed by agentic — checklist
The agent applied these fixes automatically and pushed them to the PR branch:
Required before merge — checklist
Contributor action order — these items still need a manual fix: