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 += + '
' + + "Codebase not indexed yet. Connect your GitHub repo in Studio and run Index repository for code-aware onboarding steps." + + "
"; + } if (data.steps && data.steps.length) { html += '
    '; data.steps.forEach(function (s) { @@ -296,12 +508,15 @@ html += '
    Sources
    '; data.sources.forEach(function (s) { html += "· " + escapeHtml(s.title); + var u = safeUrl(s.url); + if (u) html += ' (open)'; if (s.excerpt) html += " — " + escapeHtml(s.excerpt.slice(0, 140)) + (s.excerpt.length > 140 ? "…" : ""); html += "
    "; }); html += "
    "; } addBot(html || "(empty)"); + maybeHighlight(q, data || {}); } catch { addBot("Network error — is " + 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] = useState([]); const [completedStepsByMessage, setCompletedStepsByMessage] = useState>>({}); const [activeSource, setActiveSource] = useState(null); + const highlightCleanupRef = useRef<() => void>(() => undefined); + useEffect(() => { + return () => { + highlightCleanupRef.current?.(); + }; + }, []); const posWrap = position === "embedded" ? "relative h-full min-h-[360px] w-full overflow-hidden bg-slate-50" : ""; const fixedLaunch = position === "page" ? "fixed bottom-5 right-5 z-[2147483000]" : "absolute bottom-4 right-4 z-20"; @@ -108,8 +115,22 @@ export function EmbeddedRunbookAssistant({ setMessages((m) => [...m, { role: "assistant", text: data.error || `Request failed (${res.status})` }]); return; } + const sourceCount = Array.isArray(data.sources) ? data.sources.length : 0; + const missingIndexSignal = + data.mode === "unindexed" || + /AI is not configured|no indexed chunks yet/i.test(data.answer || "") || + sourceCount === 0; + setMessages((m) => [ ...m, + ...(missingIndexSignal + ? [ + { + role: "assistant" as const, + text: "Codebase not indexed yet. Connect your GitHub repo in Studio and run Index repository for code-aware onboarding steps." + } + ] + : []), { role: "assistant", answer: data.answer, @@ -118,6 +139,27 @@ export function EmbeddedRunbookAssistant({ text: !data.answer ? "No content." : undefined } ]); + highlightCleanupRef.current(); + const cleanup = maybeHighlightElementForQuestion(q, data); + if (!cleanup) { + const answerText = String(data.answer || ""); + const looksLikeTargetedAnswer = + /(look for|labeled|labelled|button|cta|click|create account|sign up|get started|log in|register)/i.test( + answerText + ) || (Array.isArray(data.steps) && data.steps.length > 0); + if (isLocationIntent(q) && !looksLikeTargetedAnswer) { + setMessages((m) => [ + ...m, + { + role: "assistant", + text: "I couldn't confidently find that element on this page. Try the exact label (e.g. Create account) or ask me to explain this page." + } + ]); + } + highlightCleanupRef.current = () => undefined; + } else { + highlightCleanupRef.current = cleanup; + } } catch { setMessages((m) => [...m, { role: "assistant", text: "Network error — try again." }]); } finally { @@ -342,3 +384,139 @@ export function EmbeddedRunbookAssistant({ ); } +function maybeHighlightElementForQuestion(question: string, data: ChatResponse): (() => void) | null { + if (typeof document === "undefined") return () => undefined; + if (!isLocationIntent(question)) return () => undefined; + + ensureHighlightStyleTag(); + const hint = [question, data.answer || "", (data.steps || []).join(" ")].join(" "); + const target = findBestTarget(hint, question); + if (!target) return null; + + target.classList.add("rb-highlight-target"); + target.scrollIntoView({ behavior: "smooth", block: "center" }); + + const overlay = document.createElement("div"); + overlay.className = "rb-highlight-overlay"; + overlay.textContent = data.steps?.[0] || "Start here for this action."; + const rect = target.getBoundingClientRect(); + overlay.style.top = `${window.scrollY + rect.bottom + 8}px`; + overlay.style.left = `${window.scrollX + Math.max(12, rect.left)}px`; + document.body.appendChild(overlay); + + const timeout = window.setTimeout(() => { + target.classList.remove("rb-highlight-target"); + overlay.remove(); + }, 8000); + + return () => { + window.clearTimeout(timeout); + target.classList.remove("rb-highlight-target"); + overlay.remove(); + }; +} + +function ensureHighlightStyleTag(): void { + if (document.getElementById("rb-page-highlight-style")) return; + const 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 findBestTarget(hint: string, question: string): HTMLElement | null { + const tokens = tokenize(hint).slice(0, 20); + if (tokens.length === 0) return null; + const intentCompact = compact(extractIntentPhrase(question)); + const nodes = Array.from( + document.querySelectorAll( + "button,a,[role='button'],summary,label,h1,h2,h3,input,textarea,select,[data-testid],[aria-label],nav a" + ) + ); + let best: HTMLElement | null = null; + let bestScore = 0; + + for (const node of nodes) { + const rect = node.getBoundingClientRect(); + if (rect.width < 16 || rect.height < 10) continue; + const text = [ + node.innerText || "", + node.getAttribute("aria-label") || "", + node.getAttribute("title") || "", + node.id || "", + node.getAttribute("name") || "", + node.getAttribute("placeholder") || "", + node.className || "" + ] + .join(" ") + .toLowerCase(); + if (!text.trim()) continue; + + let score = 0; + const compactText = compact(text); + for (const token of tokens) { + const compactToken = compact(token); + if (text.includes(token)) score += token.length > 5 ? 3 : 2; + if (compactToken && compactText.includes(compactToken)) score += token.length > 5 ? 3 : 2; + } + if (intentCompact && compactText.includes(intentCompact)) score += 8; + if (/(create|sign\s*up|signup|register|get\s*started|start|continue|next)/i.test(text)) score += 3; + if (node.tagName === "BUTTON" || node.tagName === "A") score += 2; + if (score > bestScore) { + bestScore = score; + best = node; + } + } + + return bestScore >= 3 ? best : null; +} + +function tokenize(text: string): string[] { + return String(text) + .toLowerCase() + .replace(/([a-z])([A-Z])/g, "$1 $2") + .replace(/[^a-z0-9\s]/g, " ") + .split(/\s+/) + .filter((token) => token.length >= 2); +} + +function compact(text: string): string { + return String(text).toLowerCase().replace(/[^a-z0-9]/g, ""); +} + +function stripTrailingSentencePunctuation(input: string): string { + let end = input.length; + while (end > 0) { + const ch = input[end - 1]; + if (ch === "." || ch === "!" || ch === "?") { + end -= 1; + continue; + } + break; + } + return input.slice(0, end); +} + +function extractIntentPhrase(question: string): string { + const q = String(question || ""); + const lower = q.toLowerCase(); + const prefixes = ["where is ", "find ", "locate ", "click ", "open ", "go to "]; + for (const prefix of prefixes) { + const idx = lower.indexOf(prefix); + if (idx >= 0) { + const rawTarget = q.slice(idx + prefix.length).trim(); + const cleaned = stripTrailingSentencePunctuation(rawTarget).trim(); + if (cleaned.length > 0) return cleaned; + } + } + return ""; +} + +function isLocationIntent(text: string): boolean { + 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 || "") + ); +} diff --git a/src/lib/embedDemoKnowledge.ts b/src/lib/embedDemoKnowledge.ts index 8a9b234..6b30859 100644 --- a/src/lib/embedDemoKnowledge.ts +++ b/src/lib/embedDemoKnowledge.ts @@ -17,6 +17,48 @@ function excerptFromContent(content: string, max = 180): string { return content.replace(/\s+/g, " ").trim().slice(0, max) + (content.length > max ? "…" : ""); } +function isLocationIntent(text: string): boolean { + const q = String(text || "").toLowerCase(); + return ( + /\b(where|locate|click|open)\b/.test(q) || + /\bgo\s+to\b/.test(q) || + /\b(where\s+is|where\s+can\s+i)\b/.test(q) || + /\b(get\s+started|create\s+account|sign\s*up|signup|register|log\s*in|login)\b/.test(q) + ); +} + +function stripTrailingSentencePunctuation(input: string): string { + let end = input.length; + while (end > 0) { + const ch = input[end - 1]; + if (ch === "." || ch === "!" || ch === "?") { + end -= 1; + continue; + } + break; + } + return input.slice(0, end); +} + +function extractLocationTarget(text: string): string { + const lower = text.toLowerCase(); + const prefixes = ["where is ", "find ", "locate ", "click ", "open ", "go to "]; + for (const prefix of prefixes) { + const idx = lower.indexOf(prefix); + if (idx >= 0) { + const rawTarget = text.slice(idx + prefix.length).trim(); + const cleaned = stripTrailingSentencePunctuation(rawTarget).trim(); + if (cleaned.length > 0) return cleaned; + } + } + if (/create\s*account/i.test(text)) return "Create account"; + if (/sign\s*up|signup/i.test(text)) return "Sign up"; + if (/get\s*started/i.test(text)) return "Get started"; + if (/log\s*in|login/i.test(text)) return "Log in"; + if (/register/i.test(text)) return "Register"; + return "the relevant action button"; +} + /** Deterministic demo responses for hackathon reliability. */ export function buildNorthstarDemoResponse(message: string, pageContext: string): DemoChatResult { const m = message.toLowerCase().trim(); @@ -28,6 +70,23 @@ export function buildNorthstarDemoResponse(message: string, pageContext: string) const product = docById("product-overview"); const expense = docById("expense-policy"); + if (isLocationIntent(m)) { + const target = extractLocationTarget(message); + return { + answer: + `I can help you locate **${target}** on this page. The exact workflow may not be fully documented in demo excerpts, so I will guide by visible UI labels.`, + sources: product + ? [{ title: product.title, excerpt: excerptFromContent(product.content), url: undefined }] + : [], + steps: [ + `Look for a control labeled **${target}** on the current page.`, + "Use the highlighted element as your starting point if one appears.", + "If multiple matches exist, choose the most prominent primary CTA in the onboarding section.", + "If nothing is highlighted, ask with the exact visible label (for example: find Create account button)." + ] + }; + } + const explainPage = m.includes("explain this page") || m.includes("what is this page") || diff --git a/src/lib/embedNorthstarChat.ts b/src/lib/embedNorthstarChat.ts index a9bf631..8138027 100644 --- a/src/lib/embedNorthstarChat.ts +++ b/src/lib/embedNorthstarChat.ts @@ -7,6 +7,8 @@ import { retrieveKeywordSources } from "./embedKeywordRetrieval"; const NORTHSTAR_SYSTEM = `You are Runbook, an embedded in-app onboarding assistant for the "Northstar AI" demo product. Use ONLY the knowledge excerpts provided in the user message. If something is not in the excerpts, say briefly that it is not documented and suggest where to look next. Be practical and concise (under 180 words for the main answer). +For onboarding/how-to questions, infer likely end-user flow from the provided excerpts: where to begin in UI, what to click/type next, and how to verify success. +For location intents (where/find/locate/create account/sign up/get started/login), keep the answer short and action-first so a page highlighter can guide the user immediately. After your answer, on a NEW final line, output exactly this format (single line): RUNBOOK_STEPS_JSON: ["imperative step 1", "step 2", ...] Use 3-6 short imperative steps. No markdown code fences.`; diff --git a/src/lib/embedRetrieval.ts b/src/lib/embedRetrieval.ts index 5b9decb..0793f94 100644 --- a/src/lib/embedRetrieval.ts +++ b/src/lib/embedRetrieval.ts @@ -26,26 +26,54 @@ export type EmbedRetrievedDoc = { score: number; }; +function buildEmbedQueries(question: string): string[] { + const q = question.trim(); + if (!q) return []; + const lower = q.toLowerCase(); + const queries = [q]; + const flowIntent = /(steps|how do i|how to|start using|onboard|register|sign up|signup|create account|login|log in)/i; + if (flowIntent.test(lower)) { + queries.push( + `${q}\nFocus on user onboarding flow from code: routes, controllers, API handlers, UI forms, submit actions, redirects, and required verification steps.`, + `${q}\nFind entrypoint screens and sequence: sign up, email verification, first login, workspace setup, and first successful action.` + ); + } + return queries; +} + export async function retrieveDocsForEmbed(question: string, projectId: string): Promise { const TOP_K = 6; const marker = embedScopeLine(projectId); - const embedding = await generateEmbedding(question); - if (!embedding) return []; + const queries = buildEmbedQueries(question); + const merged = new Map(); - const { data: documents, error } = await supabaseAdmin.rpc("match_documents", { - query_embedding: `[${embedding.join(",")}]`, - match_threshold: DEFAULT_MATCH_THRESHOLD, - match_count: TOP_K * 8 - }); + const queryResults = await Promise.all( + queries.map(async (query) => { + const embedding = await generateEmbedding(query); + if (!embedding) return null; + const { data: documents, error } = await supabaseAdmin.rpc("match_documents", { + query_embedding: `[${embedding.join(",")}]`, + match_threshold: DEFAULT_MATCH_THRESHOLD, + match_count: TOP_K * 4 + }); + if (error) { + console.error("Embed vector search error:", error); + return null; + } + return (documents || []) as MatchedDocument[]; + }) + ); - if (error) { - console.error("Embed vector search error:", error); - return []; + for (const batch of queryResults) { + if (!batch) continue; + for (const doc of batch) { + if (!matchesEmbedScope(doc.content, projectId)) continue; + const prev = merged.get(doc.id); + if (!prev || doc.similarity > prev.similarity) merged.set(doc.id, doc); + } } - const batch = (documents || []) as MatchedDocument[]; - const scoped = batch.filter((d) => matchesEmbedScope(d.content, projectId)); - return scoped + return Array.from(merged.values()) .sort((a, b) => b.similarity - a.similarity) .slice(0, TOP_K) .map((doc) => ({ diff --git a/src/lib/prompts.ts b/src/lib/prompts.ts index 18c6f11..752b472 100644 --- a/src/lib/prompts.ts +++ b/src/lib/prompts.ts @@ -1,6 +1,12 @@ export const EMBED_CHAT_SYSTEM_PROMPT = `You are an in-app help assistant embedded on a customer's website. End users ask how to use the product. Use ONLY the provided indexed repository excerpts and metadata. Do not reveal secrets, tokens, or internal employee onboarding content unrelated to this product. If the answer is not in the context, say so briefly and suggest what UI area or doc to check next. +When users ask for onboarding or "how to start", infer the end-user flow from code and docs by identifying: +- likely UI entry points (routes/pages/components/forms), +- backend/API handlers involved, +- required user actions in order, +- verification signals after each step. +Prefer concrete, user-facing guidance over implementation details. Formatting: - Use "## Summary" and "## Steps" (numbered) when giving how-to guidance. diff --git a/test-embed.html b/test-embed.html new file mode 100644 index 0000000..9b5b149 --- /dev/null +++ b/test-embed.html @@ -0,0 +1,101 @@ + + + + + + Runbook Embed Test Page + + + +
    +

    Runbook Embed Test Harness

    +

    Use this page to validate highlight targeting and onboarding guidance.

    + +
    +

    Account onboarding

    +
    + + + + Continue +
    +
    + +
    +

    Login flow

    + + + + +
    + + +
    +
    + +
    +

    Try these prompts in Runbook

    +
      +
    • where is createaccount
    • +
    • find sign up button
    • +
    • where is get started
    • +
    • what are the steps to register and start using this service?
    • +
    +
    +
    + + + + \ No newline at end of file