diff --git a/package-lock.json b/package-lock.json
index 30ce0e9..95e753f 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -74,6 +74,7 @@
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@babel/code-frame": "^7.29.0",
"@babel/generator": "^7.29.0",
@@ -1270,6 +1271,7 @@
"resolved": "https://registry.npmjs.org/@octokit/core/-/core-6.1.6.tgz",
"integrity": "sha512-kIU8SLQkYWGp3pVKiYzA5OSaNF5EE03P/R8zEmmrG6XwOg5oBjXyQVVIauQ0dgau4zYhpZEhJrvIYt6oM+zZZA==",
"license": "MIT",
+ "peer": true,
"dependencies": {
"@octokit/auth-token": "^5.0.0",
"@octokit/graphql": "^8.2.2",
@@ -1581,6 +1583,7 @@
"resolved": "https://registry.npmjs.org/@supabase/supabase-js/-/supabase-js-2.104.1.tgz",
"integrity": "sha512-E0H/CtVmaGjiAy+ieZ5ZB/1EqxXcGdaFaAc23AE5zaYfz6NtCNDcmaEdoGPYMPFH5pE6drGG6e3ljPmkFoGVxQ==",
"license": "MIT",
+ "peer": true,
"dependencies": {
"@supabase/auth-js": "2.104.1",
"@supabase/functions-js": "2.104.1",
@@ -1919,6 +1922,7 @@
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"csstype": "^3.2.2"
}
@@ -1993,6 +1997,7 @@
"integrity": "sha512-TI1XGwKbDpo9tRW8UDIXCOeLk55qe9ZFGs8MTKU6/M08HWTw52DD/IYhfQtOEhEdPhLMT26Ka/x7p70nd3dzDg==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "8.59.0",
"@typescript-eslint/types": "8.59.0",
@@ -2518,6 +2523,7 @@
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
"dev": true,
"license": "MIT",
+ "peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -2922,6 +2928,7 @@
}
],
"license": "MIT",
+ "peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.10.12",
"caniuse-lite": "^1.0.30001782",
@@ -3539,6 +3546,7 @@
"integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.1",
@@ -3724,6 +3732,7 @@
"integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@rtsao/scc": "^1.1.0",
"array-includes": "^3.1.9",
@@ -6325,6 +6334,7 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz",
"integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==",
"license": "MIT",
+ "peer": true,
"engines": {
"node": ">=0.10.0"
}
@@ -6334,6 +6344,7 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz",
"integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==",
"license": "MIT",
+ "peer": true,
"dependencies": {
"scheduler": "^0.27.0"
},
@@ -7050,6 +7061,7 @@
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"dev": true,
"license": "MIT",
+ "peer": true,
"engines": {
"node": ">=12"
},
@@ -7212,6 +7224,7 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
+ "peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -7528,6 +7541,7 @@
"integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==",
"dev": true,
"license": "MIT",
+ "peer": true,
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
diff --git a/public/runbook-embed.js b/public/runbook-embed.js
index f0bd8fb..6179489 100644
--- a/public/runbook-embed.js
+++ b/public/runbook-embed.js
@@ -18,6 +18,9 @@
var projectId =
script.getAttribute("data-project-id") || script.getAttribute("data-project") || "";
var apiKey = script.getAttribute("data-key") || "";
+ var includeBodyText =
+ script.hasAttribute("data-include-body-text") ||
+ !!document.querySelector('script[src*="runbook-embed.js"][data-include-body-text]');
var originAttr = (script.getAttribute("data-runbook-origin") || "").trim().replace(/\/$/, "");
var base = originAttr || script.src.replace(/\/runbook-embed\.js.*$/, "");
if (!projectId) {
@@ -26,6 +29,25 @@
}
var DEMO_ID = "northstar-demo";
+ function isLocationIntent(text) {
+ return /(where|find|locate|click|open|go to|how do i|create account|sign up|signup|register|get started|log in|login)/i.test(
+ String(text || "")
+ );
+ }
+
+ function stripTrailingSentencePunctuation(input) {
+ var end = input.length;
+ while (end > 0) {
+ var ch = input[end - 1];
+ if (ch === "." || ch === "!" || ch === "?") {
+ end -= 1;
+ continue;
+ }
+ break;
+ }
+ return input.slice(0, end);
+ }
+
var BUNDLE_KEY = "runbook_demo_bundle_v1";
var IMPORTED_DOCS_KEY = "runbook_imported_docs";
var IMPORTED_PROJECT_ID_KEY = "runbook_project_id";
@@ -200,6 +222,151 @@
var inp = foot.querySelector(".rb-inp");
var sendBtn = foot.querySelector(".rb-send");
var closeBtn = head.querySelector(".rb-close");
+ var highlightTimeout = null;
+ var highlightedEl = null;
+ var overlayEl = null;
+
+ function ensurePageHighlightStyles() {
+ if (document.getElementById("rb-page-highlight-style")) return;
+ var styleTag = document.createElement("style");
+ styleTag.id = "rb-page-highlight-style";
+ styleTag.textContent =
+ "@keyframes rbPagePulse{0%{box-shadow:0 0 0 3px rgba(99,102,241,.95);}50%{box-shadow:0 0 0 8px rgba(99,102,241,.25);}100%{box-shadow:0 0 0 3px rgba(99,102,241,.95);}}" +
+ ".rb-highlight-target{animation:rbPagePulse 1.2s ease-in-out infinite !important;box-shadow:0 0 0 3px rgba(99,102,241,.95) !important;border-radius:8px !important;}" +
+ ".rb-highlight-overlay{position:absolute;z-index:2147483647;max-width:320px;background:#1e1b4b;color:#eef2ff;border:1px solid rgba(129,140,248,.5);border-radius:10px;padding:10px 12px;font:12px/1.4 system-ui,-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;box-shadow:0 10px 30px rgba(30,27,75,.45);}";
+ document.head.appendChild(styleTag);
+ }
+
+ function clearPageHighlight() {
+ if (highlightTimeout) {
+ clearTimeout(highlightTimeout);
+ highlightTimeout = null;
+ }
+ if (highlightedEl) {
+ highlightedEl.classList.remove("rb-highlight-target");
+ highlightedEl = null;
+ }
+ if (overlayEl && overlayEl.parentNode) {
+ overlayEl.parentNode.removeChild(overlayEl);
+ overlayEl = null;
+ }
+ }
+
+ function tokenize(text) {
+ return String(text || "")
+ .toLowerCase()
+ .replace(/([a-z])([A-Z])/g, "$1 $2")
+ .replace(/[^a-z0-9\s]/g, " ")
+ .split(/\s+/)
+ .filter(function (t) {
+ return t.length >= 2;
+ });
+ }
+
+ function compact(text) {
+ return String(text || "").toLowerCase().replace(/[^a-z0-9]/g, "");
+ }
+
+ function extractIntentPhrase(question) {
+ var q = String(question || "");
+ var lower = q.toLowerCase();
+ var prefixes = ["where is ", "find ", "locate ", "click ", "open ", "go to "];
+ for (var i = 0; i < prefixes.length; i++) {
+ var prefix = prefixes[i];
+ var idx = lower.indexOf(prefix);
+ if (idx >= 0) {
+ var rawTarget = q.slice(idx + prefix.length).trim();
+ var cleaned = stripTrailingSentencePunctuation(rawTarget).trim();
+ if (cleaned.length > 0) return cleaned;
+ }
+ }
+ return "";
+ }
+
+ function scoreCandidate(el, tokens, intentCompact) {
+ var label = [
+ el.innerText || "",
+ el.getAttribute("aria-label") || "",
+ el.getAttribute("title") || "",
+ el.id || "",
+ el.getAttribute("name") || "",
+ el.getAttribute("placeholder") || "",
+ el.className || ""
+ ]
+ .join(" ")
+ .toLowerCase();
+ if (!label.trim()) return 0;
+ var score = 0;
+ var compactLabel = compact(label);
+ tokens.forEach(function (t) {
+ var compactToken = compact(t);
+ if (label.indexOf(t) !== -1) score += t.length > 5 ? 3 : 2;
+ if (compactToken && compactLabel.indexOf(compactToken) !== -1) score += t.length > 5 ? 3 : 2;
+ });
+ if (intentCompact && compactLabel.indexOf(intentCompact) !== -1) score += 8;
+ if (/(create|sign\s*up|signup|register|get\s*started|start|continue|next)/i.test(label)) score += 3;
+ if (el.tagName === "BUTTON" || el.tagName === "A") score += 2;
+ var rect = el.getBoundingClientRect();
+ if (rect.width < 16 || rect.height < 10) score = 0;
+ return score;
+ }
+
+ function findBestElement(textHint, question) {
+ var tokens = tokenize(textHint).slice(0, 20);
+ if (!tokens.length) return null;
+ var intentCompact = compact(extractIntentPhrase(question));
+ var selector =
+ "button,a,[role='button'],summary,label,h1,h2,h3,input,textarea,select,[data-testid],[aria-label],nav a";
+ var nodes = document.querySelectorAll(selector);
+ var best = null;
+ var bestScore = 0;
+ for (var i = 0; i < nodes.length; i++) {
+ var el = nodes[i];
+ if (!el || !el.getBoundingClientRect) continue;
+ var rect = el.getBoundingClientRect();
+ if (rect.bottom < 0 || rect.top > window.innerHeight * 2) continue;
+ var s = scoreCandidate(el, tokens, intentCompact);
+ if (s > bestScore) {
+ bestScore = s;
+ best = el;
+ }
+ }
+ return bestScore >= 3 ? best : null;
+ }
+
+ function showPageOverlay(target, text) {
+ if (!target) return;
+ var rect = target.getBoundingClientRect();
+ var overlay = document.createElement("div");
+ overlay.className = "rb-highlight-overlay";
+ overlay.textContent = text || "Start here.";
+ var top = window.scrollY + rect.bottom + 8;
+ var left = window.scrollX + Math.max(12, rect.left);
+ overlay.style.top = String(top) + "px";
+ overlay.style.left = String(left) + "px";
+ document.body.appendChild(overlay);
+ overlayEl = overlay;
+ }
+
+ function maybeHighlight(question, data) {
+ if (!isLocationIntent(question || "")) return;
+ ensurePageHighlightStyles();
+ clearPageHighlight();
+ var hint = [question || "", data.answer || "", (data.steps || []).join(" ")].join(" ");
+ var target = findBestElement(hint, question);
+ if (!target) {
+ addBot(
+ "I couldn't confidently find that element on this page.
Try the exact button/link text (e.g. Create account) or ask me to explain this page."
+ );
+ return;
+ }
+ highlightedEl = target;
+ target.classList.add("rb-highlight-target");
+ target.scrollIntoView({ behavior: "smooth", block: "center" });
+ var tip = data.steps && data.steps.length ? data.steps[0] : "This is likely where to start for your question.";
+ showPageOverlay(target, tip);
+ highlightTimeout = setTimeout(clearPageHighlight, 8000);
+ }
function pageContext() {
var t = document.title || "";
@@ -207,7 +374,41 @@
var meta = "";
var m = document.querySelector('meta[name="description"]');
if (m) meta = m.getAttribute("content") || "";
- return [u, t, meta].filter(Boolean).join("\n");
+ var headings = "";
+ try {
+ var hs = Array.prototype.slice.call(document.querySelectorAll("h1,h2,h3")).map(function (el) {
+ return (el && el.textContent ? String(el.textContent) : "").trim();
+ }).filter(Boolean).slice(0, 12);
+ headings = hs.join(" | ");
+ } catch {
+ headings = "";
+ }
+ var bodyText = "";
+ if (includeBodyText) {
+ try {
+ var clone = document.body ? document.body.cloneNode(true) : null;
+ if (clone && clone.querySelectorAll) {
+ var fields = clone.querySelectorAll("input,textarea,select");
+ for (var i = 0; i < fields.length; i++) {
+ var f = fields[i];
+ var tag = (f.tagName || "").toLowerCase();
+ if (tag === "select") {
+ f.textContent = "";
+ } else {
+ f.setAttribute("value", "");
+ f.textContent = "";
+ }
+ }
+ }
+ bodyText = (clone && clone.innerText ? clone.innerText : "")
+ .replace(/\s+/g, " ")
+ .trim()
+ .slice(0, 12000);
+ } catch {
+ bodyText = "";
+ }
+ }
+ return [u, t, meta, headings, bodyText].filter(Boolean).join("\n");
}
function addBot(html) {
@@ -285,6 +486,17 @@
}
var html = "";
if (data.answer) html += escapeHtml(data.answer).replace(/\n/g, "
");
+ var sourceCount = Array.isArray(data.sources) ? data.sources.length : 0;
+ var missingIndexSignal =
+ data.mode === "unindexed" ||
+ /AI is not configured|no indexed chunks yet/i.test(String(data.answer || "")) ||
+ sourceCount === 0;
+ if (missingIndexSignal) {
+ html +=
+ '
" + escapeHtml(base) + " reachable from this page?");
} finally {
@@ -317,6 +532,17 @@
.replace(/"/g, """);
}
+ function safeUrl(raw) {
+ if (!raw) return "";
+ try {
+ var u = new URL(String(raw), location.href);
+ if (u.protocol !== "http:" && u.protocol !== "https:") return "";
+ return u.toString();
+ } catch {
+ return "";
+ }
+ }
+
sendBtn.addEventListener("click", function () {
void doSend();
});
diff --git a/src/app/api/embed/chat/route.ts b/src/app/api/embed/chat/route.ts
index 0757b1d..99c3673 100644
--- a/src/app/api/embed/chat/route.ts
+++ b/src/app/api/embed/chat/route.ts
@@ -25,7 +25,13 @@ function corsHeaders(originAllow: string | null): HeadersInit {
function originAllowed(projectSite: string | undefined, origin: string | null): boolean {
if (!projectSite || !origin) return true;
- return origin.includes(projectSite) || projectSite.includes(origin.replace(/^https?:\/\//, ""));
+ try {
+ const allowed = new URL(projectSite.includes("://") ? projectSite : `https://${projectSite}`);
+ const actual = new URL(origin);
+ return actual.hostname === allowed.hostname || actual.hostname.endsWith(`.${allowed.hostname}`);
+ } catch {
+ return false;
+ }
}
export async function OPTIONS(req: NextRequest) {
@@ -121,7 +127,7 @@ export async function POST(req: NextRequest) {
const customSources = [...sanitizeCustomSources(body.customSources), ...requestDocs];
const payload = await runNorthstarEmbedChat({ message, pageContext, customSources });
- return NextResponse.json(payload, { headers: corsHeaders(origin) });
+ return NextResponse.json({ ...payload, mode: "demo" }, { headers: corsHeaders(origin) });
}
const auth = req.headers.get("authorization") || "";
@@ -183,6 +189,7 @@ Also output 3-6 short imperative steps as a JSON array string at the very end on
answer:
"AI is not configured. Set GEMINI_API_KEY and/or OPENAI_API_KEY in .env.local for grounded answers from your indexed repository.",
sources: baseSources,
+ mode: "unindexed",
steps: [
"Connect knowledge in Runbook Studio.",
"Run **Index repository** so chunks exist in the vector store.",
@@ -218,6 +225,7 @@ Also output 3-6 short imperative steps as a JSON array string at the very end on
{
answer,
sources: baseSources,
+ mode: "live",
steps
},
{ headers: corsHeaders(origin) }
@@ -225,8 +233,18 @@ Also output 3-6 short imperative steps as a JSON array string at the very end on
} catch (e) {
console.error(e);
return NextResponse.json(
- { error: "AI temporarily unavailable", sources: [], steps: [] },
- { status: 503, headers: corsHeaders(origin) }
+ {
+ answer:
+ "Runbook AI is temporarily unavailable. I can still guide you from indexed docs below while full reasoning recovers.",
+ sources: baseSources,
+ mode: "error",
+ steps: [
+ "Open the most relevant source below and confirm the exact policy text.",
+ "Follow the documented path in order, then retry this question for a deeper walkthrough.",
+ "If the action is still blocked, share the exact error message with your onboarding owner."
+ ]
+ },
+ { headers: corsHeaders(origin) }
);
}
}
diff --git a/src/components/EmbeddedRunbookAssistant.tsx b/src/components/EmbeddedRunbookAssistant.tsx
index 5a31168..e2c6206 100644
--- a/src/components/EmbeddedRunbookAssistant.tsx
+++ b/src/components/EmbeddedRunbookAssistant.tsx
@@ -1,6 +1,6 @@
"use client";
-import { useCallback, useMemo, useState } from "react";
+import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
loadImportedDocs,
loadProjectId,
@@ -13,6 +13,7 @@ type ChatResponse = {
error?: string;
sources?: SourceItem[];
steps?: string[];
+ mode?: string;
};
type SourceItem = { title: string; excerpt?: string; url?: string };
@@ -56,6 +57,12 @@ export function EmbeddedRunbookAssistant({
const [messages, setMessages] = useStateUse this page to validate highlight targeting and onboarding guidance.
+ +where is createaccountfind sign up buttonwhere is get startedwhat are the steps to register and start using this service?