From 6fd7a613b71b10e043669ac47e7f6b77108793f3 Mon Sep 17 00:00:00 2001 From: Bryan Zhu Date: Sat, 25 Apr 2026 18:26:52 -0700 Subject: [PATCH 1/2] fix: improve embed location intent guidance and highlighting Improve demo embed chat handling for location-intent prompts, enrich page context, and harden UI highlight targeting so create-account and sign-up queries produce actionable guidance instead of generic fallback text. Made-with: Cursor --- package-lock.json | 14 ++ public/runbook-embed.js | 178 +++++++++++++++++- src/app/api/embed/chat/route.ts | 27 ++- src/components/EmbeddedRunbookAssistant.tsx | 190 +++++++++++++++++++- src/lib/embedDemoKnowledge.ts | 34 ++++ src/lib/embedNorthstarChat.ts | 2 + src/lib/embedRetrieval.ts | 51 ++++-- src/lib/prompts.ts | 6 + test-embed.html | 101 +++++++++++ 9 files changed, 584 insertions(+), 19 deletions(-) create mode 100644 test-embed.html 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..8af908f 100644 --- a/public/runbook-embed.js +++ b/public/runbook-embed.js @@ -26,6 +26,12 @@ } 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 || "") + ); + } + var BUNDLE_KEY = "runbook_demo_bundle_v1"; var IMPORTED_DOCS_KEY = "runbook_imported_docs"; var IMPORTED_PROJECT_ID_KEY = "runbook_project_id"; @@ -200,6 +206,142 @@ 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 m = q.match(/(?:where\s+is|find|locate|click|open|go\s+to)\s+(.+)$/i); + if (!m || !m[1]) return ""; + return m[1].trim().replace(/[?.!]+$/, ""); + } + + 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 +349,16 @@ var meta = ""; var m = document.querySelector('meta[name="description"]'); if (m) meta = m.getAttribute("content") || ""; - return [u, t, meta].filter(Boolean).join("\n"); + var bodyText = ""; + try { + bodyText = (document.body && document.body.innerText ? document.body.innerText : "") + .replace(/\s+/g, " ") + .trim() + .slice(0, 12000); + } catch { + bodyText = ""; + } + return [u, t, meta, bodyText].filter(Boolean).join("\n"); } function addBot(html) { @@ -285,6 +436,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 === "fallback" || + /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 +458,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 +482,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..3de54ba 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 allowed.hostname === actual.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") || ""; @@ -169,6 +175,7 @@ ${context || "(no indexed chunks yet)"} User question: ${message} +If this is an onboarding/how-to question, infer the concrete user journey from the indexed code/docs: identify entry UI, exact actions, and what success looks like after each action. Also output 3-6 short imperative steps as a JSON array string at the very end on its own line prefixed exactly with RUNBOOK_STEPS_JSON: e.g. RUNBOOK_STEPS_JSON: ["step1","step2"]`; const baseSources = retrieved.map((r) => ({ @@ -183,6 +190,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: "fallback", steps: [ "Connect knowledge in Runbook Studio.", "Run **Index repository** so chunks exist in the vector store.", @@ -218,6 +226,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 +234,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: "fallback", + 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..926fe25 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, 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,7 @@ export function EmbeddedRunbookAssistant({ const [messages, setMessages] = useState([]); const [completedStepsByMessage, setCompletedStepsByMessage] = useState>>({}); const [activeSource, setActiveSource] = useState(null); + const highlightCleanupRef = useRef<() => void>(() => undefined); 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 +110,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 === "fallback" || + /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 +134,20 @@ export function EmbeddedRunbookAssistant({ text: !data.answer ? "No content." : undefined } ]); + highlightCleanupRef.current(); + const cleanup = maybeHighlightElementForQuestion(q, data); + if (!cleanup) { + 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 +372,161 @@ 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 = findBestTargetWithIntent(hint, question) || findBestTarget(hint); + 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): HTMLElement | null { + const tokens = tokenize(hint).slice(0, 20); + if (tokens.length === 0) return null; + 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 (node.tagName === "BUTTON" || node.tagName === "A") score += 2; + if (score > bestScore) { + bestScore = score; + best = node; + } + } + + return bestScore >= 3 ? best : null; +} + +function findBestTargetWithIntent(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 extractIntentPhrase(question: string): string { + const m = String(question).match(/(?:where\s+is|find|locate|click|open|go\s+to)\s+(.+)$/i); + if (!m || !m[1]) return ""; + return m[1].trim().replace(/[?.!]+$/, ""); +} + +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..45076d9 100644 --- a/src/lib/embedDemoKnowledge.ts +++ b/src/lib/embedDemoKnowledge.ts @@ -17,6 +17,23 @@ function excerptFromContent(content: string, max = 180): string { return content.replace(/\s+/g, " ").trim().slice(0, max) + (content.length > max ? "…" : ""); } +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( + text + ); +} + +function extractLocationTarget(text: string): string { + const m = text.match(/(?:where\s+is|find|locate|click|open|go\s+to)\s+(.+)$/i); + if (m && m[1]) return m[1].trim().replace(/[?.!]+$/, ""); + 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 +45,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 that action on this page. The exact account creation flow is not documented in the current 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..4b8df2d 100644 --- a/src/lib/embedRetrieval.ts +++ b/src/lib/embedRetrieval.ts @@ -26,26 +26,51 @@ 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(); + + for (const query of queries) { + const embedding = await generateEmbedding(query); + if (!embedding) continue; + + const { data: documents, error } = await supabaseAdmin.rpc("match_documents", { + query_embedding: `[${embedding.join(",")}]`, + match_threshold: DEFAULT_MATCH_THRESHOLD, + match_count: TOP_K * 8 + }); - const { data: documents, error } = await supabaseAdmin.rpc("match_documents", { - query_embedding: `[${embedding.join(",")}]`, - match_threshold: DEFAULT_MATCH_THRESHOLD, - match_count: TOP_K * 8 - }); + if (error) { + console.error("Embed vector search error:", error); + continue; + } - if (error) { - console.error("Embed vector search error:", error); - return []; + const batch = (documents || []) as MatchedDocument[]; + 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..819b056 --- /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

    +

    Email

    + +

    Password

    + +
    + + +
    +
    + +
    +

    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 From 73a3b88e860273f546f8616b99825913d9457e17 Mon Sep 17 00:00:00 2001 From: Bryan Zhu Date: Sat, 25 Apr 2026 18:49:10 -0700 Subject: [PATCH 2/2] fix: address security and reliability review findings Harden embed privacy and error semantics, tighten location-intent handling, improve highlight cleanup behavior, parallelize embed retrieval queries, and update the test harness accessibility labels. Made-with: Cursor --- public/runbook-embed.js | 72 +++++++++++-- src/app/api/embed/chat/route.ts | 7 +- src/components/EmbeddedRunbookAssistant.tsx | 106 +++++++++----------- src/lib/embedDemoKnowledge.ts | 35 ++++++- src/lib/embedRetrieval.ts | 33 +++--- test-embed.html | 8 +- 6 files changed, 164 insertions(+), 97 deletions(-) diff --git a/public/runbook-embed.js b/public/runbook-embed.js index 8af908f..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) { @@ -32,6 +35,19 @@ ); } + 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"; @@ -253,9 +269,18 @@ function extractIntentPhrase(question) { var q = String(question || ""); - var m = q.match(/(?:where\s+is|find|locate|click|open|go\s+to)\s+(.+)$/i); - if (!m || !m[1]) return ""; - return m[1].trim().replace(/[?.!]+$/, ""); + 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) { @@ -349,16 +374,41 @@ var meta = ""; var m = document.querySelector('meta[name="description"]'); if (m) meta = m.getAttribute("content") || ""; - var bodyText = ""; + var headings = ""; try { - bodyText = (document.body && document.body.innerText ? document.body.innerText : "") - .replace(/\s+/g, " ") - .trim() - .slice(0, 12000); + 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 { - bodyText = ""; + 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, bodyText].filter(Boolean).join("\n"); + return [u, t, meta, headings, bodyText].filter(Boolean).join("\n"); } function addBot(html) { @@ -438,7 +488,7 @@ if (data.answer) html += escapeHtml(data.answer).replace(/\n/g, "
    "); var sourceCount = Array.isArray(data.sources) ? data.sources.length : 0; var missingIndexSignal = - data.mode === "fallback" || + data.mode === "unindexed" || /AI is not configured|no indexed chunks yet/i.test(String(data.answer || "")) || sourceCount === 0; if (missingIndexSignal) { diff --git a/src/app/api/embed/chat/route.ts b/src/app/api/embed/chat/route.ts index 3de54ba..99c3673 100644 --- a/src/app/api/embed/chat/route.ts +++ b/src/app/api/embed/chat/route.ts @@ -28,7 +28,7 @@ function originAllowed(projectSite: string | undefined, origin: string | null): try { const allowed = new URL(projectSite.includes("://") ? projectSite : `https://${projectSite}`); const actual = new URL(origin); - return allowed.hostname === actual.hostname; + return actual.hostname === allowed.hostname || actual.hostname.endsWith(`.${allowed.hostname}`); } catch { return false; } @@ -175,7 +175,6 @@ ${context || "(no indexed chunks yet)"} User question: ${message} -If this is an onboarding/how-to question, infer the concrete user journey from the indexed code/docs: identify entry UI, exact actions, and what success looks like after each action. Also output 3-6 short imperative steps as a JSON array string at the very end on its own line prefixed exactly with RUNBOOK_STEPS_JSON: e.g. RUNBOOK_STEPS_JSON: ["step1","step2"]`; const baseSources = retrieved.map((r) => ({ @@ -190,7 +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: "fallback", + mode: "unindexed", steps: [ "Connect knowledge in Runbook Studio.", "Run **Index repository** so chunks exist in the vector store.", @@ -238,7 +237,7 @@ Also output 3-6 short imperative steps as a JSON array string at the very end on answer: "Runbook AI is temporarily unavailable. I can still guide you from indexed docs below while full reasoning recovers.", sources: baseSources, - mode: "fallback", + 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.", diff --git a/src/components/EmbeddedRunbookAssistant.tsx b/src/components/EmbeddedRunbookAssistant.tsx index 926fe25..e2c6206 100644 --- a/src/components/EmbeddedRunbookAssistant.tsx +++ b/src/components/EmbeddedRunbookAssistant.tsx @@ -1,6 +1,6 @@ "use client"; -import { useCallback, useMemo, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { loadImportedDocs, loadProjectId, @@ -58,6 +58,11 @@ export function EmbeddedRunbookAssistant({ 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"; @@ -112,7 +117,7 @@ export function EmbeddedRunbookAssistant({ } const sourceCount = Array.isArray(data.sources) ? data.sources.length : 0; const missingIndexSignal = - data.mode === "fallback" || + data.mode === "unindexed" || /AI is not configured|no indexed chunks yet/i.test(data.answer || "") || sourceCount === 0; @@ -137,13 +142,20 @@ export function EmbeddedRunbookAssistant({ highlightCleanupRef.current(); const cleanup = maybeHighlightElementForQuestion(q, data); if (!cleanup) { - 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." - } - ]); + 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; @@ -378,7 +390,7 @@ function maybeHighlightElementForQuestion(question: string, data: ChatResponse): ensureHighlightStyleTag(); const hint = [question, data.answer || "", (data.steps || []).join(" ")].join(" "); - const target = findBestTargetWithIntent(hint, question) || findBestTarget(hint); + const target = findBestTarget(hint, question); if (!target) return null; target.classList.add("rb-highlight-target"); @@ -415,51 +427,7 @@ function ensureHighlightStyleTag(): void { document.head.appendChild(styleTag); } -function findBestTarget(hint: string): HTMLElement | null { - const tokens = tokenize(hint).slice(0, 20); - if (tokens.length === 0) return null; - 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 (node.tagName === "BUTTON" || node.tagName === "A") score += 2; - if (score > bestScore) { - bestScore = score; - best = node; - } - } - - return bestScore >= 3 ? best : null; -} - -function findBestTargetWithIntent(hint: string, question: string): HTMLElement | null { +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)); @@ -519,10 +487,32 @@ 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 m = String(question).match(/(?:where\s+is|find|locate|click|open|go\s+to)\s+(.+)$/i); - if (!m || !m[1]) return ""; - return m[1].trim().replace(/[?.!]+$/, ""); + 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 { diff --git a/src/lib/embedDemoKnowledge.ts b/src/lib/embedDemoKnowledge.ts index 45076d9..6b30859 100644 --- a/src/lib/embedDemoKnowledge.ts +++ b/src/lib/embedDemoKnowledge.ts @@ -18,14 +18,39 @@ function excerptFromContent(content: string, max = 180): string { } 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( - text + 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 m = text.match(/(?:where\s+is|find|locate|click|open|go\s+to)\s+(.+)$/i); - if (m && m[1]) return m[1].trim().replace(/[?.!]+$/, ""); + 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"; @@ -49,7 +74,7 @@ export function buildNorthstarDemoResponse(message: string, pageContext: string) const target = extractLocationTarget(message); return { answer: - "I can help you locate that action on this page. The exact account creation flow is not documented in the current demo excerpts, so I will guide by visible UI labels.", + `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 }] : [], diff --git a/src/lib/embedRetrieval.ts b/src/lib/embedRetrieval.ts index 4b8df2d..0793f94 100644 --- a/src/lib/embedRetrieval.ts +++ b/src/lib/embedRetrieval.ts @@ -47,22 +47,25 @@ export async function retrieveDocsForEmbed(question: string, projectId: string): const queries = buildEmbedQueries(question); const merged = new Map(); - for (const query of queries) { - const embedding = await generateEmbedding(query); - if (!embedding) continue; + 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[]; + }) + ); - const { data: documents, error } = await supabaseAdmin.rpc("match_documents", { - query_embedding: `[${embedding.join(",")}]`, - match_threshold: DEFAULT_MATCH_THRESHOLD, - match_count: TOP_K * 8 - }); - - if (error) { - console.error("Embed vector search error:", error); - continue; - } - - const batch = (documents || []) as MatchedDocument[]; + 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); diff --git a/test-embed.html b/test-embed.html index 819b056..9b5b149 100644 --- a/test-embed.html +++ b/test-embed.html @@ -73,10 +73,10 @@

    Account onboarding

    Login flow

    -

    Email

    - -

    Password

    - + + + +