Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
774e74e
feat: integrate Flowise AI chatbot widget with full dark/light mode t…
jonathansmirnoff Jun 9, 2026
b593eb8
fix: disable thumbs rating buttons after first vote to prevent duplic…
jonathansmirnoff Jun 10, 2026
8c18cfa
feat: add GA for chatbot
jonathansmirnoff Jun 10, 2026
4fa0156
feat: add flowise apikey
jonathansmirnoff Jun 16, 2026
a8eb467
feat: move Flowise auth to proxy, drop client-side API key
jonathansmirnoff Jun 30, 2026
bf17ce9
Merge branch 'main' into feat/DX-1914/integrate-flowise-chat-widget
jonathansmirnoff Jul 22, 2026
ccbd120
feat: rebrand chatbot launcher and add starter prompts
jonathansmirnoff Jul 22, 2026
6d6e109
feat: add "Ask Rootstock AI" button to code blocks
jonathansmirnoff Jul 22, 2026
e49fd60
feat: add copy-to-clipboard for code blocks and inline values in chatbot
jonathansmirnoff Jul 22, 2026
f6f7032
feat: restyle Ask Rootstock navbar button as outline pill with brand …
jonathansmirnoff Jul 22, 2026
f7e90bd
Merge branch 'main' into feat/DX-1914/integrate-flowise-chat-widget
jonathansmirnoff Aug 18, 2026
46b4e1a
fix: bump websocket-driver to 0.7.5 to resolve critical advisory
jonathansmirnoff Aug 18, 2026
98b5651
fix: pin seroval to 1.6.2 to resolve critical/high advisories
jonathansmirnoff Aug 18, 2026
7510f90
fix: bump @babel/preset-env to 7.29.7 to resolve high-severity advisory
jonathansmirnoff Aug 18, 2026
01e4ecc
fix: bump axios, form-data and pin solid-js to resolve remaining high…
jonathansmirnoff Aug 18, 2026
9ab6dbe
fix: force multer to 2.2.0 to resolve DoS advisories
jonathansmirnoff Aug 18, 2026
20d22a0
feat: add FLOWISE_CHATBOT_ENABLED kill switch for the chatbot widget
jonathansmirnoff Aug 18, 2026
a4a3d72
fix: default FLOWISE_CHATBOT_ENABLED to disabled
jonathansmirnoff Aug 19, 2026
0d260ec
fix: address Copilot review findings on the Flowise chatbot
jonathansmirnoff Aug 19, 2026
002b6c5
fix: keep single-key page shortcuts from eating chat input
jonathansmirnoff Aug 20, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Copy link
Copy Markdown
Collaborator

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.md

Summary

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:

  • ✅ AI-style pattern detected (lines 38, 41, 44 · 🔵 Nitpick · 🎨 Rootstock.607-Robotics)

Required before merge — checklist

Contributor action order — these items still need a manual fix:

  • ⬜ Use 'turn off' or 'off' instead of 'disabled' (line 40 · 🔵 Nitpick · 🎨 Rootstock.605-Word-list)


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:
Comment thread
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).

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Nitpick | 🎨 Rootstock.605-Word-list | Auto Fix not Applied
Use 'turn off' or 'off' instead of 'disabled'.

- `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`).
Comment thread
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.
Comment thread
jonathansmirnoff marked this conversation as resolved.

## Usage

### Production Build
Expand Down
3 changes: 3 additions & 0 deletions docusaurus.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,9 @@ const config = {
mendable : process.env.MENDABLE_KEY,
cookbook : process.env.COOKBOOK_PUBLIC_API_KEY,
github : process.env.GITHUB_TOKEN,
flowiseApiHost : process.env.FLOWISE_API_HOST,
flowiseChatflowId : process.env.FLOWISE_CHATFLOW_ID,
flowiseChatbotEnabled : process.env.FLOWISE_CHATBOT_ENABLED === 'true',
},
homePage: {
editUrl: 'https://github.com/rsksmart/devportal/tree/main/',
Expand Down
18,705 changes: 8,325 additions & 10,380 deletions package-lock.json

Large diffs are not rendered by default.

11 changes: 11 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The 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 overrides is correct, but both lockfiles are committed here, and each honors its own mechanism:

lockfile pinned by resolves to
package-lock.json (v3, npm) overrides seroval 1.6.2, solid-js 1.9.15, multer 2.2.0
yarn.lock (v1, yarn) resolutions the same three

So overrides is load-bearing, not duplication: it is the only thing pinning the npm lockfile. Dropping it would let the next npm install regenerate package-lock.json with the vulnerable transitive versions (solid-js 1.7.1, seroval ~1.5.4, multer 1.4.5), undoing 98b5651, 01e4ecc and 9ab6dbe and re-breaking the dependency-review check.

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%",
Expand Down
92 changes: 92 additions & 0 deletions src/_utils/flowiseChat.js
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 };
}
1 change: 1 addition & 0 deletions src/scss/app.scss
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
'components/tabs',
'components/accordion',
'components/ai-button',
'components/ask-rootstock-button',
'components/codeblock',
'components/accessibe-widget',
'components/sliders',
Expand Down
52 changes: 52 additions & 0 deletions src/scss/components/_ask-rootstock-button.scss
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;
}
}
136 changes: 136 additions & 0 deletions src/theme/CodeBlock/Buttons/AskAiButton/askFlowise.js
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;
}
Loading
Loading