HoopAI AI Chat Widget Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: UseGoal: Replace the broken VoiceGlow/ConvoCore widget with a fully custom Claude Haiku 4.5 chat panel backed by a Cloudflare Worker proxy that keeps all API keys off the client. Architecture: A Cloudflare Worker (superpowers:subagent-driven-development(recommended) orsuperpowers:executing-plansto implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.
worker/src/index.js) receives chat requests from the browser, runs an agentic tool loop (web search, support ticket creation), then streams the final Claude response back via SSE. The browser widget (theme/chat-widget.js) is a self-contained IIFE that renders a right-side panel matching Mintlify’s native AI drawer — slides in and pushes page content left.
Tech Stack: Cloudflare Workers, Wrangler CLI v3, OpenRouter API (anthropic/claude-haiku-4-5), Vanilla JS (ES5-compatible IIFE), CSS custom properties (--ds-* tokens already in theme/custom.css).
File Map
| File | Action | Responsibility |
|---|---|---|
worker/package.json | Create | Wrangler dev dependency |
worker/wrangler.toml | Create | Worker name, compat date, var stubs |
worker/src/index.js | Create | CORS, routing, agentic loop, tool execution, SSE streaming |
theme/chat-widget.js | Rewrite | Panel UI, streaming client, Ask AI button injections |
theme/custom.css | Modify | Panel layout, push-content rule, message bubbles, dark mode |
docs.json | Verify/fix | Confirm JS path is "theme/chat-widget.js" (not bare "chat-widget.js") |
Task 1: Worker — Project Scaffold
Files:-
Create:
worker/package.json -
Create:
worker/wrangler.toml -
Create:
worker/src/index.js(skeleton only) -
Step 1.1 — Create
worker/package.json
{
"name": "hoopai-chat-api",
"version": "1.0.0",
"private": true,
"scripts": {
"dev": "wrangler dev",
"deploy": "wrangler deploy"
},
"devDependencies": {
"wrangler": "^3.99.0"
}
}
- Step 1.2 — Create
worker/wrangler.toml
name = "hoopai-chat-api"
main = "src/index.js"
compatibility_date = "2024-09-23"
compatibility_flags = ["nodejs_compat"]
# Allowed origins stored as plain var (not secret — not sensitive)
[vars]
ALLOWED_ORIGINS = "https://help.hoopai.com,http://localhost:3000"
# Secrets set via: wrangler secret put OPENROUTER_API_KEY
# Secrets set via: wrangler secret put BRAVE_SEARCH_API_KEY (optional)
# Secrets set via: wrangler secret put SUPPORT_WEBHOOK_URL (optional)
- Step 1.3 — Create skeleton
worker/src/index.js
export default {
async fetch(request, env) {
return new Response('hoopai-chat-api ok', { status: 200 });
}
};
- Step 1.4 — Install Wrangler and verify scaffold
cd worker
npm install
npx wrangler --version
3.x.x (Wrangler version number)
- Step 1.5 — Commit scaffold
cd ..
git add worker/
git commit -m "feat: scaffold Cloudflare Worker for AI chat proxy"
Task 2: Worker — CORS + Routing + OpenRouter Streaming Proxy
Files:-
Modify:
worker/src/index.js(replace skeleton with full implementation) - Step 2.1 — Smoke-test the current skeleton locally
cd worker
CLOUDFLARE_API_TOKEN=cfk_wrWstQqIhihprafWZ4hoTLmTFla3nZZmtyhpJ2kyb70eecc0 npx wrangler dev --port 8787
curl http://localhost:8787/
hoopai-chat-api ok
Stop the dev server (Ctrl+C).
- Step 2.2 — Replace
worker/src/index.jswith full implementation
/**
* hoopai-chat-api — Cloudflare Worker
* Proxies OpenRouter (Claude Haiku 4.5) with tool support.
* Secrets: OPENROUTER_API_KEY, BRAVE_SEARCH_API_KEY, SUPPORT_WEBHOOK_URL
*/
const OPENROUTER_URL = 'https://openrouter.ai/api/v1/chat/completions';
const MODEL = 'anthropic/claude-haiku-4-5';
const MAX_TOOL_ITERATIONS = 4;
const TOOL_DEFINITIONS = [
{
type: 'function',
function: {
name: 'web_search',
description:
'Search the web for current information. Use when the user asks about something not in your training or that may have changed recently.',
parameters: {
type: 'object',
properties: {
query: { type: 'string', description: 'The search query' }
},
required: ['query']
}
}
},
{
type: 'function',
function: {
name: 'create_support_ticket',
description:
'Create a HoopAI support ticket when the user has an unresolved issue that needs human assistance.',
parameters: {
type: 'object',
properties: {
subject: { type: 'string', description: 'One-line summary of the issue' },
description: { type: 'string', description: 'Full description of the problem' },
email: { type: 'string', description: "User's email address, if they provided it" }
},
required: ['subject', 'description']
}
}
}
];
/* ── CORS ─────────────────────────────────────────────────── */
function getAllowedOrigins(env) {
return (env.ALLOWED_ORIGINS || 'https://help.hoopai.com,http://localhost:3000').split(',');
}
function corsHeaders(origin) {
return {
'Access-Control-Allow-Origin': origin,
'Access-Control-Allow-Methods': 'POST, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type',
'Access-Control-Max-Age': '86400'
};
}
/* ── System prompt ────────────────────────────────────────── */
function buildSystemPrompt(pageContext) {
const page = pageContext || {};
return [
'You are a helpful support assistant for HoopAI — an all-in-one CRM and marketing platform.',
'You are embedded in the HoopAI Help Center documentation site.',
page.title ? `The user is currently reading: "${page.title}" (${page.url})` : '',
'',
'Guidelines:',
'- Answer questions about HoopAI features directly and concisely.',
'- When asked about something that may have changed or is not in your training, use web_search.',
'- When a user has an unresolved issue needing human help, offer to use create_support_ticket.',
'- Never mention GoHighLevel, HighLevel, GHL, Marketing Muse, or WhoPayI.',
'- Always say "HoopAI" or "Hoop" — never the white-label vendor names.',
'- The mobile app is called LeadConnector (not HoopAI) — important for App Store searches.',
'- Be direct and concise. No filler phrases like "Great question!" or "Certainly!".'
].filter(Boolean).join('\n');
}
/* ── OpenRouter call ──────────────────────────────────────── */
async function callOpenRouter(messages, env, stream) {
const body = {
model: MODEL,
messages,
tools: TOOL_DEFINITIONS,
tool_choice: 'auto',
stream
};
const res = await fetch(OPENROUTER_URL, {
method: 'POST',
headers: {
Authorization: `Bearer ${env.OPENROUTER_API_KEY}`,
'Content-Type': 'application/json',
'HTTP-Referer': 'https://help.hoopai.com',
'X-Title': 'HoopAI Help Center'
},
body: JSON.stringify(body)
});
if (!res.ok) {
const err = await res.text();
throw new Error(`OpenRouter error ${res.status}: ${err}`);
}
return res;
}
/* ── Tool execution ───────────────────────────────────────── */
async function executeWebSearch(query, env) {
if (!env.BRAVE_SEARCH_API_KEY) {
return { error: 'Web search not configured. Answer from your training data.' };
}
const url = `https://api.search.brave.com/res/v1/web/search?q=${encodeURIComponent(query)}&count=5`;
const res = await fetch(url, {
headers: { Accept: 'application/json', 'Accept-Encoding': 'gzip', 'X-Subscription-Token': env.BRAVE_SEARCH_API_KEY }
});
if (!res.ok) return { error: `Search failed: ${res.status}` };
const data = await res.json();
const results = (data.web?.results || []).slice(0, 5).map(r => ({
title: r.title,
url: r.url,
snippet: r.description
}));
return { results };
}
async function executeCreateTicket(params, env) {
if (!env.SUPPORT_WEBHOOK_URL) {
return {
success: false,
message: 'Ticket system not configured yet. Please email support@hoopai.com directly.'
};
}
const res = await fetch(env.SUPPORT_WEBHOOK_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ subject: params.subject, description: params.description, email: params.email || '' })
});
if (!res.ok) return { success: false, message: `Webhook error: ${res.status}` };
return { success: true, message: 'Support ticket created. The team will respond within 24 hours.' };
}
async function executeTool(name, args, env) {
if (name === 'web_search') return executeWebSearch(args.query, env);
if (name === 'create_support_ticket') return executeCreateTicket(args, env);
return { error: `Unknown tool: ${name}` };
}
/* ── Agentic loop → SSE stream ────────────────────────────── */
function sse(obj) {
return `data: ${JSON.stringify(obj)}\n\n`;
}
async function runAndStream(initialMessages, env, origin) {
const { readable, writable } = new TransformStream();
const writer = writable.getWriter();
const enc = new TextEncoder();
const write = (obj) => writer.write(enc.encode(sse(obj)));
(async () => {
try {
let messages = [...initialMessages];
// Agentic loop: non-streaming calls to handle tool calls
for (let i = 0; i < MAX_TOOL_ITERATIONS; i++) {
const res = await callOpenRouter(messages, env, false);
const data = await res.json();
const choice = data.choices?.[0];
if (!choice) throw new Error('No choice returned from OpenRouter');
if (choice.finish_reason === 'tool_calls' && choice.message?.tool_calls?.length) {
// Append assistant message with tool calls
messages.push({ role: 'assistant', content: null, tool_calls: choice.message.tool_calls });
// Execute each tool call
for (const tc of choice.message.tool_calls) {
let args = {};
try { args = JSON.parse(tc.function.arguments); } catch (_) {}
await write({ type: 'tool_start', tool: tc.function.name, input: args });
const result = await executeTool(tc.function.name, args, env);
await write({ type: 'tool_end', tool: tc.function.name, result });
messages.push({
role: 'tool',
tool_call_id: tc.id,
content: JSON.stringify(result)
});
}
// Loop again for the assistant's next response
} else {
// No tool calls — do a streaming final response
const streamRes = await callOpenRouter(messages, env, true);
const reader = streamRes.body.getReader();
const dec = new TextDecoder();
let buf = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
buf += dec.decode(value, { stream: true });
const lines = buf.split('\n');
buf = lines.pop(); // keep incomplete line
for (const line of lines) {
if (!line.startsWith('data: ')) continue;
const raw = line.slice(6).trim();
if (raw === '[DONE]') break;
try {
const parsed = JSON.parse(raw);
const delta = parsed.choices?.[0]?.delta?.content;
if (delta) await write({ type: 'token', content: delta });
} catch (_) {}
}
}
break;
}
}
await write({ type: 'done' });
} catch (err) {
await write({ type: 'error', message: err.message });
} finally {
writer.close();
}
})();
return new Response(readable, {
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
...corsHeaders(origin)
}
});
}
/* ── Main handler ─────────────────────────────────────────── */
export default {
async fetch(request, env) {
const origin = request.headers.get('Origin') || '';
const allowed = getAllowedOrigins(env);
// CORS preflight
if (request.method === 'OPTIONS') {
const isAllowed = allowed.includes(origin);
return new Response(null, {
status: 204,
headers: isAllowed ? corsHeaders(origin) : {}
});
}
// Block non-allowed origins
if (!allowed.includes(origin)) {
return new Response('Forbidden', { status: 403 });
}
const url = new URL(request.url);
if (request.method === 'POST' && url.pathname === '/chat') {
let body;
try {
body = await request.json();
} catch (_) {
return new Response('Invalid JSON', { status: 400, headers: corsHeaders(origin) });
}
const { messages = [], pageContext = {} } = body;
if (!Array.isArray(messages) || messages.length === 0) {
return new Response('messages array required', { status: 400, headers: corsHeaders(origin) });
}
const systemMsg = { role: 'system', content: buildSystemPrompt(pageContext) };
const allMessages = [systemMsg, ...messages];
return runAndStream(allMessages, env, origin);
}
return new Response('Not found', { status: 404, headers: corsHeaders(origin) });
}
};
- Step 2.3 — Start worker dev server and test CORS preflight
cd worker
CLOUDFLARE_API_TOKEN=cfk_wrWstQqIhihprafWZ4hoTLmTFla3nZZmtyhpJ2kyb70eecc0 npx wrangler dev --port 8787
curl -X OPTIONS http://localhost:8787/chat \
-H "Origin: http://localhost:3000" \
-H "Access-Control-Request-Method: POST" \
-v 2>&1 | grep -E "HTTP|access-control|< "
HTTP/1.1 204 and access-control-allow-origin: http://localhost:3000
- Step 2.4 — Test blocked origin
curl -X OPTIONS http://localhost:8787/chat \
-H "Origin: https://evil.com" -v 2>&1 | grep "HTTP/"
HTTP/1.1 403
- Step 2.5 — Test basic streaming (requires OPENROUTER_API_KEY as local secret)
worker/.dev.vars (gitignored, local only):
OPENROUTER_API_KEY=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
.dev.vars is automatically gitignored by Wrangler — it’s the Wrangler equivalent of .env for local dev.
Restart the dev server, then:
curl -X POST http://localhost:8787/chat \
-H "Content-Type: application/json" \
-H "Origin: http://localhost:3000" \
-d '{"messages":[{"role":"user","content":"Say hello in 5 words"}],"pageContext":{"title":"Test","url":"http://localhost:3000"}}' \
--no-buffer
data: {"type":"token","content":"..."} lines ending with data: {"type":"done"}
- Step 2.6 — Commit worker implementation
cd ..
git add worker/src/index.js
git commit -m "feat: implement Cloudflare Worker chat proxy with agentic tool loop"
Task 3: Deploy Worker and Set Secrets
Files: No code changes — deployment and secret configuration only.- Step 3.1 — Deploy to Cloudflare
cd worker
CLOUDFLARE_API_TOKEN=cfk_wrWstQqIhihprafWZ4hoTLmTFla3nZZmtyhpJ2kyb70eecc0 npx wrangler deploy
Published hoopai-chat-api (xx sec)
https://hoopai-chat-api.<account-id>.workers.dev
- Step 3.2 — Set the OpenRouter API key secret
CLOUDFLARE_API_TOKEN=cfk_wrWstQqIhihprafWZ4hoTLmTFla3nZZmtyhpJ2kyb70eecc0 \
npx wrangler secret put OPENROUTER_API_KEY
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
✓ Success! Uploaded secret OPENROUTER_API_KEY
- Step 3.3 — Verify deployed worker responds
<WORKER_URL> with the URL from Step 3.1:
curl -X POST https://<WORKER_URL>/chat \
-H "Content-Type: application/json" \
-H "Origin: https://help.hoopai.com" \
-d '{"messages":[{"role":"user","content":"Say hi in 3 words"}],"pageContext":{}}' \
--no-buffer
{"type":"done"}
- Step 3.4 — Commit deployment artifacts
cd ..
git add worker/
git commit -m "chore: add worker .dev.vars to gitignore note, deployment verified"
worker/.dev.vars to .gitignore if not already covered by .env.* rule:
Open .gitignore and add under the secrets section:
worker/.dev.vars
git add .gitignore
git commit -m "chore: gitignore worker/.dev.vars local secrets"
Task 4: Widget CSS — Panel Styles
Files:-
Modify:
theme/custom.css(append at end of file) -
Step 4.1 — Append panel CSS to
theme/custom.css
theme/custom.css to find the last line number, then append:
/* ═══════════════════════════════════════════════════════════
HOOPAI AI CHAT PANEL
Right-side drawer that pushes #content-side-layout left.
All colours use --ds-* tokens → auto dark/light mode.
═══════════════════════════════════════════════════════════ */
/* Panel container */
#hoopai-panel {
position: fixed;
top: 0;
right: 0;
height: 100vh;
width: 380px;
background: var(--ds-surface-page);
border-left: 1px solid var(--ds-border);
display: flex;
flex-direction: column;
z-index: 50;
transform: translateX(100%);
transition: transform var(--ds-transition-slow), width var(--ds-transition-slow);
box-shadow: var(--ds-shadow-lg);
font-family: inherit;
}
#hoopai-panel.hoopai-open {
transform: translateX(0);
}
#hoopai-panel.hoopai-expanded {
width: 680px;
}
/* Push page content left when panel is open */
body.hoopai-panel-open #content-side-layout {
margin-right: 380px !important;
transition: margin-right var(--ds-transition-slow) !important;
}
body.hoopai-panel-open.hoopai-panel-expanded #content-side-layout {
margin-right: 680px !important;
}
/* Panel header */
#hoopai-panel-header {
display: flex;
align-items: center;
gap: var(--ds-space-sm);
padding: var(--ds-space-md) var(--ds-space-lg);
border-bottom: 1px solid var(--ds-border-subtle);
flex-shrink: 0;
}
.hoopai-panel-title {
font-size: var(--ds-font-sm);
font-weight: var(--ds-weight-semibold);
color: var(--ds-text);
flex: 1;
}
.hoopai-panel-action-btn {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border: none;
background: transparent;
border-radius: var(--ds-radius-sm);
color: var(--ds-text-tertiary);
cursor: pointer;
transition: background var(--ds-transition), color var(--ds-transition);
flex-shrink: 0;
}
.hoopai-panel-action-btn:hover {
background: var(--ds-surface-hover);
color: var(--ds-text-secondary);
}
/* Messages area */
#hoopai-panel-messages {
flex: 1;
overflow-y: auto;
padding: var(--ds-space-lg);
display: flex;
flex-direction: column;
gap: var(--ds-space-md);
scroll-behavior: smooth;
}
/* Empty state */
.hoopai-empty-state {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--ds-space-lg);
padding: var(--ds-space-4xl) var(--ds-space-lg) 0;
text-align: center;
}
.hoopai-empty-icon {
width: 40px;
height: 40px;
color: var(--ds-accent);
opacity: 0.8;
}
.hoopai-empty-title {
font-size: var(--ds-font-md);
font-weight: var(--ds-weight-semibold);
color: var(--ds-text);
margin: 0;
}
.hoopai-empty-sub {
font-size: var(--ds-font-sm);
color: var(--ds-text-secondary);
margin: 0;
line-height: 1.5;
}
.hoopai-suggestions {
display: flex;
flex-direction: column;
gap: var(--ds-space-sm);
width: 100%;
margin-top: var(--ds-space-sm);
}
.hoopai-suggestion-chip {
text-align: left;
padding: var(--ds-space-sm) var(--ds-space-md);
border-radius: var(--ds-radius-md);
border: 1px solid var(--ds-border);
background: var(--ds-surface-raised);
color: var(--ds-text-secondary);
font-size: var(--ds-font-sm);
cursor: pointer;
transition: background var(--ds-transition), color var(--ds-transition), border-color var(--ds-transition);
line-height: 1.4;
}
.hoopai-suggestion-chip:hover {
background: var(--ds-accent-light);
border-color: var(--ds-accent);
color: var(--ds-text);
}
/* Message bubbles */
.hoopai-msg {
display: flex;
flex-direction: column;
gap: var(--ds-space-xs);
max-width: 90%;
}
.hoopai-msg-user {
align-self: flex-end;
}
.hoopai-msg-assistant {
align-self: flex-start;
}
.hoopai-msg-bubble {
padding: var(--ds-space-sm) var(--ds-space-md);
border-radius: var(--ds-radius-lg);
font-size: var(--ds-font-sm);
line-height: 1.6;
white-space: pre-wrap;
word-break: break-word;
}
.hoopai-msg-user .hoopai-msg-bubble {
background: var(--ds-accent-light);
color: var(--ds-text);
border-bottom-right-radius: var(--ds-radius-sm);
}
.hoopai-msg-assistant .hoopai-msg-bubble {
background: var(--ds-surface-raised);
color: var(--ds-text);
border-bottom-left-radius: var(--ds-radius-sm);
box-shadow: var(--ds-shadow-sm);
}
/* Typing indicator */
.hoopai-typing {
display: flex;
align-items: center;
gap: 4px;
padding: 10px var(--ds-space-md);
background: var(--ds-surface-raised);
border-radius: var(--ds-radius-lg);
border-bottom-left-radius: var(--ds-radius-sm);
align-self: flex-start;
box-shadow: var(--ds-shadow-sm);
}
.hoopai-typing-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--ds-text-tertiary);
animation: hoopai-pulse 1.2s infinite ease-in-out;
}
.hoopai-typing-dot:nth-child(2) { animation-delay: 0.2s; }
.hoopai-typing-dot:nth-child(3) { animation-delay: 0.4s; }
@keyframes hoopai-pulse {
0%, 80%, 100% { transform: scale(0.8); opacity: 0.5; }
40% { transform: scale(1); opacity: 1; }
}
/* Tool call pills */
.hoopai-tool-pill {
display: inline-flex;
align-items: center;
gap: var(--ds-space-xs);
padding: 4px var(--ds-space-sm);
border-radius: var(--ds-radius-full);
background: var(--ds-surface-inset);
border: 1px solid var(--ds-border-subtle);
font-size: 11px;
color: var(--ds-text-secondary);
align-self: flex-start;
}
.hoopai-tool-pill.done {
color: var(--ds-accent);
border-color: var(--ds-accent-light);
background: var(--ds-accent-light);
}
/* Page context pill (shown above input when page context is set) */
.hoopai-context-pill {
display: flex;
align-items: center;
gap: var(--ds-space-xs);
padding: 4px var(--ds-space-sm);
border-radius: var(--ds-radius-full);
background: var(--ds-surface-inset);
border: 1px solid var(--ds-border-subtle);
font-size: 11px;
color: var(--ds-text-secondary);
flex-shrink: 0;
max-width: 100%;
overflow: hidden;
}
.hoopai-context-pill span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* Footer / input area */
#hoopai-panel-footer {
padding: var(--ds-space-md) var(--ds-space-lg);
border-top: 1px solid var(--ds-border-subtle);
display: flex;
flex-direction: column;
gap: var(--ds-space-sm);
flex-shrink: 0;
}
.hoopai-input-row {
display: flex;
align-items: flex-end;
gap: var(--ds-space-sm);
background: var(--ds-surface-raised);
border: 1px solid var(--ds-border);
border-radius: var(--ds-radius-lg);
padding: var(--ds-space-sm) var(--ds-space-sm) var(--ds-space-sm) var(--ds-space-md);
transition: border-color var(--ds-transition);
}
.hoopai-input-row:focus-within {
border-color: var(--ds-accent);
}
#hoopai-input {
flex: 1;
background: transparent;
border: none;
outline: none;
resize: none;
font-family: inherit;
font-size: var(--ds-font-sm);
color: var(--ds-text);
line-height: 1.5;
max-height: 120px;
overflow-y: auto;
padding: 0;
min-height: 22px;
}
#hoopai-input::placeholder {
color: var(--ds-text-tertiary);
}
#hoopai-send-btn {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border-radius: var(--ds-radius-sm);
border: none;
background: var(--ds-accent);
color: #fff;
cursor: pointer;
flex-shrink: 0;
transition: opacity var(--ds-transition);
}
#hoopai-send-btn:disabled {
opacity: 0.4;
cursor: default;
}
#hoopai-send-btn:not(:disabled):hover {
opacity: 0.85;
}
.hoopai-footer-hint {
font-size: 10px;
color: var(--ds-text-tertiary);
text-align: center;
}
/* Markdown-like styling inside assistant bubbles */
.hoopai-msg-assistant .hoopai-msg-bubble strong { font-weight: 600; }
.hoopai-msg-assistant .hoopai-msg-bubble code {
font-family: monospace;
font-size: 12px;
background: var(--ds-surface-inset);
padding: 1px 4px;
border-radius: 3px;
}
/* Mobile: full-width panel, no content push */
@media (max-width: 768px) {
#hoopai-panel {
width: 100vw !important;
border-left: none;
}
body.hoopai-panel-open #content-side-layout,
body.hoopai-panel-open.hoopai-panel-expanded #content-side-layout {
margin-right: 0 !important;
}
}
- Step 4.2 — Commit CSS
git add theme/custom.css
git commit -m "feat: add HoopAI chat panel CSS (right-side drawer with DS tokens)"
Task 5: Widget JS — Complete Rewrite
Files:- Rewrite:
theme/chat-widget.js
toggleWidget() is replaced by togglePanel().
- Step 5.1 — Replace
theme/chat-widget.jswith the full widget
/**
* HoopAI Help Center — Custom AI Chat Widget
* Loaded globally via docs.json "js" key.
*
* Replaces VoiceGlow/ConvoCore entirely.
* Backs onto the Cloudflare Worker proxy (WORKER_URL below).
*
* Entry points:
* 1. Navbar "Ask AI" button (desktop) → #assistant-entry
* 2. Mobile header icon → #assistant-entry-mobile
* 3. Code block sparkle buttons → data-ask-ai-injected
* 4. Page-level "Ask AI" button → #page-ask-ai-btn
* 5. Keyboard shortcut Ctrl+I / Cmd+I
*/
(function () {
'use strict';
/* ── CONFIG ──────────────────────────────────────────────── */
var WORKER_URL = 'https://hoopai-chat-api.REPLACE_WITH_ACCOUNT_ID.workers.dev';
/* ── ICONS ───────────────────────────────────────────────── */
var DS_SPARKLE =
'<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">' +
'<path d="M8 1.5L9.7 6.3L14.5 8L9.7 9.7L8 14.5L6.3 9.7L1.5 8L6.3 6.3L8 1.5Z" ' +
'stroke="currentColor" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round"/>' +
'<path d="M3 1L3.4 2.6L5 3L3.4 3.4L3 5L2.6 3.4L1 3L2.6 2.6L3 1Z" ' +
'stroke="currentColor" stroke-width="0.75" stroke-linecap="round" stroke-linejoin="round"/>' +
'</svg>';
var ICON_EXPAND =
'<svg width="14" height="14" viewBox="0 0 14 14" fill="none">' +
'<path d="M2 5L7 1L12 5M2 9L7 13L12 9" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>' +
'</svg>';
var ICON_COLLAPSE =
'<svg width="14" height="14" viewBox="0 0 14 14" fill="none">' +
'<path d="M2 3L7 7L12 3M2 11L7 7L12 11" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>' +
'</svg>';
var ICON_CLEAR =
'<svg width="14" height="14" viewBox="0 0 14 14" fill="none">' +
'<path d="M1.5 1.5L12.5 12.5M12.5 1.5L1.5 12.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>' +
'</svg>';
var ICON_CLOSE =
'<svg width="14" height="14" viewBox="0 0 14 14" fill="none">' +
'<path d="M2 2L12 12M12 2L2 12" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>' +
'</svg>';
var ICON_SEND =
'<svg width="14" height="14" viewBox="0 0 14 14" fill="none">' +
'<path d="M1 7h12M8 2l5 5-5 5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>' +
'</svg>';
var ICON_STOP =
'<svg width="12" height="12" viewBox="0 0 12 12" fill="currentColor">' +
'<rect x="2" y="2" width="8" height="8" rx="1.5"/>' +
'</svg>';
/* ── STATE ───────────────────────────────────────────────── */
var messages = []; // { role: 'user'|'assistant', content: string }
var isStreaming = false;
var abortController = null;
var isExpanded = false;
var pageContext = {}; // { title, url }
var pendingPreload = null; // text to pre-fill input (from code block clicks)
/* ── PANEL LIFECYCLE ─────────────────────────────────────── */
function getPanel() { return document.getElementById('hoopai-panel'); }
function createPanel() {
if (document.getElementById('hoopai-panel')) return;
var panel = document.createElement('div');
panel.id = 'hoopai-panel';
panel.setAttribute('role', 'dialog');
panel.setAttribute('aria-label', 'Ask AI');
panel.innerHTML = [
'<div id="hoopai-panel-header">',
' <span style="color:var(--ds-accent)">' + DS_SPARKLE + '</span>',
' <span class="hoopai-panel-title">Ask AI</span>',
' <button class="hoopai-panel-action-btn" id="hoopai-expand-btn" aria-label="Expand panel" title="Expand">' + ICON_EXPAND + '</button>',
' <button class="hoopai-panel-action-btn" id="hoopai-clear-btn" aria-label="Clear conversation" title="Clear conversation">' + ICON_CLEAR + '</button>',
' <button class="hoopai-panel-action-btn" id="hoopai-close-btn" aria-label="Close panel" title="Close">' + ICON_CLOSE + '</button>',
'</div>',
'<div id="hoopai-panel-messages"></div>',
'<div id="hoopai-panel-footer">',
' <div id="hoopai-context-area"></div>',
' <div class="hoopai-input-row">',
' <textarea id="hoopai-input" rows="1" placeholder="Ask anything about HoopAI…" aria-label="Message input"></textarea>',
' <button id="hoopai-send-btn" aria-label="Send message">' + ICON_SEND + '</button>',
' </div>',
' <p class="hoopai-footer-hint">Claude Haiku · May make mistakes</p>',
'</div>'
].join('');
document.body.appendChild(panel);
bindPanelEvents();
renderEmptyState();
}
function bindPanelEvents() {
document.getElementById('hoopai-close-btn').addEventListener('click', closePanel);
document.getElementById('hoopai-clear-btn').addEventListener('click', clearConversation);
document.getElementById('hoopai-expand-btn').addEventListener('click', toggleExpand);
var input = document.getElementById('hoopai-input');
var sendBtn = document.getElementById('hoopai-send-btn');
input.addEventListener('keydown', function (e) {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSend();
}
});
input.addEventListener('input', function () {
this.style.height = 'auto';
this.style.height = Math.min(this.scrollHeight, 120) + 'px';
});
sendBtn.addEventListener('click', function () {
if (isStreaming) {
stopStreaming();
} else {
handleSend();
}
});
}
function openPanel() {
createPanel();
updatePageContext();
var panel = getPanel();
panel.classList.add('hoopai-open');
document.body.classList.add('hoopai-panel-open');
if (isExpanded) document.body.classList.add('hoopai-panel-expanded');
if (pendingPreload) {
var input = document.getElementById('hoopai-input');
if (input) {
input.value = pendingPreload;
input.style.height = 'auto';
input.style.height = Math.min(input.scrollHeight, 120) + 'px';
input.focus();
}
pendingPreload = null;
} else {
setTimeout(function () {
var input = document.getElementById('hoopai-input');
if (input) input.focus();
}, 300);
}
}
function closePanel() {
var panel = getPanel();
if (panel) panel.classList.remove('hoopai-open');
document.body.classList.remove('hoopai-panel-open', 'hoopai-panel-expanded');
if (isStreaming) stopStreaming();
}
function togglePanel() {
var panel = getPanel();
var isOpen = panel && panel.classList.contains('hoopai-open');
if (isOpen) { closePanel(); } else { openPanel(); }
}
function toggleExpand() {
isExpanded = !isExpanded;
var panel = getPanel();
var btn = document.getElementById('hoopai-expand-btn');
if (!panel || !btn) return;
if (isExpanded) {
panel.classList.add('hoopai-expanded');
document.body.classList.add('hoopai-panel-expanded');
btn.innerHTML = ICON_COLLAPSE;
btn.title = 'Collapse panel';
} else {
panel.classList.remove('hoopai-expanded');
document.body.classList.remove('hoopai-panel-expanded');
btn.innerHTML = ICON_EXPAND;
btn.title = 'Expand panel';
}
}
function clearConversation() {
messages = [];
pageContext = {};
var msgArea = document.getElementById('hoopai-panel-messages');
var ctxArea = document.getElementById('hoopai-context-area');
if (msgArea) msgArea.innerHTML = '';
if (ctxArea) ctxArea.innerHTML = '';
if (isStreaming) stopStreaming();
renderEmptyState();
updatePageContext();
}
/* ── PAGE CONTEXT ─────────────────────────────────────────── */
function updatePageContext() {
var titleEl = document.getElementById('page-title');
var title = titleEl ? titleEl.textContent.trim() : document.title.replace(' - HoopAI Help Center', '').trim();
var url = window.location.href;
if (pageContext.url === url) return; // already set for this page
pageContext = { title: title, url: url };
var ctxArea = document.getElementById('hoopai-context-area');
if (!ctxArea) return;
ctxArea.innerHTML =
'<div class="hoopai-context-pill">' +
'<span style="flex-shrink:0">📄</span>' +
'<span>Reading: ' + escapeHtml(title) + '</span>' +
'</div>';
}
/* ── MESSAGE RENDERING ───────────────────────────────────── */
function renderEmptyState() {
var msgArea = document.getElementById('hoopai-panel-messages');
if (!msgArea) return;
var titleEl = document.getElementById('page-title');
var pageTitle = titleEl ? titleEl.textContent.trim() : 'this page';
var chips = getSuggestedPrompts(pageTitle);
msgArea.innerHTML =
'<div class="hoopai-empty-state">' +
' <svg class="hoopai-empty-icon" viewBox="0 0 40 40" fill="none">' +
' <path d="M20 4L24.3 15.7L36 20L24.3 24.3L20 36L15.7 24.3L4 20L15.7 15.7L20 4Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>' +
' <path d="M8 4L8.8 7.2L12 8L8.8 8.8L8 12L7.2 8.8L4 8L7.2 7.2L8 4Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>' +
' </svg>' +
' <p class="hoopai-empty-title">Ask me anything about HoopAI</p>' +
' <p class="hoopai-empty-sub">I know the docs, I can search the web,<br>and I can create support tickets.</p>' +
' <div class="hoopai-suggestions">' +
chips.map(function (c) {
return '<button class="hoopai-suggestion-chip">' + escapeHtml(c) + '</button>';
}).join('') +
' </div>' +
'</div>';
msgArea.querySelectorAll('.hoopai-suggestion-chip').forEach(function (btn) {
btn.addEventListener('click', function () {
var input = document.getElementById('hoopai-input');
if (!input) return;
input.value = btn.textContent;
input.dispatchEvent(new Event('input'));
handleSend();
});
});
}
function getSuggestedPrompts(pageTitle) {
var generic = [
'How do I get started with HoopAI?',
'How do I set up automated follow-up messages?',
'Where can I find my billing and subscription settings?'
];
if (!pageTitle || pageTitle === 'this page') return generic;
return [
'What does ' + pageTitle + ' do?',
'How do I set up ' + pageTitle + '?',
'What are common issues with ' + pageTitle + '?'
];
}
function appendMessage(role, content) {
var msgArea = document.getElementById('hoopai-panel-messages');
if (!msgArea) return null;
// Remove empty state
var emptyState = msgArea.querySelector('.hoopai-empty-state');
if (emptyState) emptyState.remove();
var wrapper = document.createElement('div');
wrapper.className = 'hoopai-msg hoopai-msg-' + role;
var bubble = document.createElement('div');
bubble.className = 'hoopai-msg-bubble';
bubble.textContent = content || '';
wrapper.appendChild(bubble);
msgArea.appendChild(wrapper);
msgArea.scrollTop = msgArea.scrollHeight;
return bubble;
}
function showTypingIndicator() {
var msgArea = document.getElementById('hoopai-panel-messages');
if (!msgArea) return null;
var el = document.createElement('div');
el.className = 'hoopai-typing';
el.id = 'hoopai-typing';
el.innerHTML = '<div class="hoopai-typing-dot"></div><div class="hoopai-typing-dot"></div><div class="hoopai-typing-dot"></div>';
msgArea.appendChild(el);
msgArea.scrollTop = msgArea.scrollHeight;
return el;
}
function removeTypingIndicator() {
var el = document.getElementById('hoopai-typing');
if (el) el.remove();
}
function appendToolPill(toolName) {
var msgArea = document.getElementById('hoopai-panel-messages');
if (!msgArea) return null;
var labels = { web_search: '🔍 Searching the web\u2026', create_support_ticket: '🎫 Creating support ticket\u2026' };
var el = document.createElement('div');
el.className = 'hoopai-tool-pill';
el.setAttribute('data-tool', toolName);
el.textContent = labels[toolName] || '⚙️ Running ' + toolName + '\u2026';
msgArea.appendChild(el);
msgArea.scrollTop = msgArea.scrollHeight;
return el;
}
function completeToolPill(toolName) {
var msgArea = document.getElementById('hoopai-panel-messages');
if (!msgArea) return;
var labels = { web_search: '🔍 Web search complete', create_support_ticket: '✓ Support ticket created' };
var el = msgArea.querySelector('[data-tool="' + toolName + '"]:not(.done)');
if (el) {
el.classList.add('done');
el.textContent = labels[toolName] || '✓ ' + toolName + ' complete';
}
}
/* ── STREAMING API CALL ──────────────────────────────────── */
function handleSend() {
var input = document.getElementById('hoopai-input');
if (!input) return;
var text = input.value.trim();
if (!text || isStreaming) return;
input.value = '';
input.style.height = 'auto';
messages.push({ role: 'user', content: text });
appendMessage('user', text);
streamResponse();
}
function streamResponse() {
var sendBtn = document.getElementById('hoopai-send-btn');
var input = document.getElementById('hoopai-input');
isStreaming = true;
abortController = new AbortController();
if (sendBtn) { sendBtn.innerHTML = ICON_STOP; sendBtn.disabled = false; }
if (input) input.disabled = true;
var typingEl = showTypingIndicator();
var assistantBubble = null;
var fullContent = '';
fetch(WORKER_URL + '/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ messages: messages.slice(), pageContext: pageContext }),
signal: abortController.signal
})
.then(function (res) {
if (!res.ok) throw new Error('Worker returned ' + res.status);
var reader = res.body.getReader();
var decoder = new TextDecoder();
var buf = '';
function read() {
return reader.read().then(function (chunk) {
if (chunk.done) {
finishStreaming(fullContent, sendBtn, input);
return;
}
buf += decoder.decode(chunk.value, { stream: true });
var lines = buf.split('\n');
buf = lines.pop();
lines.forEach(function (line) {
if (!line.startsWith('data: ')) return;
var raw = line.slice(6).trim();
try {
var evt = JSON.parse(raw);
handleStreamEvent(evt);
} catch (_) {}
});
return read();
});
}
function handleStreamEvent(evt) {
if (evt.type === 'token') {
if (typingEl) { typingEl.remove(); typingEl = null; }
if (!assistantBubble) {
assistantBubble = appendMessage('assistant', '');
}
fullContent += evt.content;
if (assistantBubble) assistantBubble.textContent = fullContent;
var msgArea = document.getElementById('hoopai-panel-messages');
if (msgArea) msgArea.scrollTop = msgArea.scrollHeight;
} else if (evt.type === 'tool_start') {
if (typingEl) { typingEl.remove(); typingEl = null; }
appendToolPill(evt.tool);
} else if (evt.type === 'tool_end') {
completeToolPill(evt.tool);
typingEl = showTypingIndicator();
} else if (evt.type === 'done') {
finishStreaming(fullContent, sendBtn, input);
} else if (evt.type === 'error') {
removeTypingIndicator();
appendMessage('assistant', 'Sorry, something went wrong: ' + (evt.message || 'Unknown error'));
finishStreaming('', sendBtn, input);
}
}
return read();
})
.catch(function (err) {
if (err.name === 'AbortError') return;
removeTypingIndicator();
appendMessage('assistant', 'Connection error. Please check your network and try again.');
finishStreaming('', sendBtn, input);
});
}
function finishStreaming(content, sendBtn, input) {
removeTypingIndicator();
isStreaming = false;
abortController = null;
if (content) messages.push({ role: 'assistant', content: content });
if (sendBtn) { sendBtn.innerHTML = ICON_SEND; sendBtn.disabled = false; }
if (input) { input.disabled = false; input.focus(); }
}
function stopStreaming() {
if (abortController) abortController.abort();
removeTypingIndicator();
isStreaming = false;
abortController = null;
var sendBtn = document.getElementById('hoopai-send-btn');
var input = document.getElementById('hoopai-input');
if (sendBtn) { sendBtn.innerHTML = ICON_SEND; sendBtn.disabled = false; }
if (input) { input.disabled = false; input.focus(); }
}
/* ── HELPERS ─────────────────────────────────────────────── */
function escapeHtml(str) {
return String(str)
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"');
}
function getNearestHeading(el) {
var node = el;
while (node && node !== document.body) {
var sibling = node.previousElementSibling;
while (sibling) {
if (/^H[2-4]$/.test(sibling.tagName)) {
return sibling.textContent.replace(/\s+/g, ' ').trim();
}
sibling = sibling.previousElementSibling;
}
node = node.parentElement;
}
return null;
}
/* ─────────────────────────────────────────────────────────────
* ASK AI BUTTON INJECTIONS
* Identical IDs to previous VoiceGlow widget — CSS in
* custom.css already styles #assistant-entry etc.
* ───────────────────────────────────────────────────────────── */
function injectAskAIButton() {
if (document.getElementById('assistant-entry')) return;
var searchBar = document.getElementById('search-bar-entry');
if (!searchBar) return;
var btn = document.createElement('button');
btn.type = 'button';
btn.id = 'assistant-entry';
btn.setAttribute('aria-label', 'Ask AI');
btn.className = 'flex-none hidden lg:flex items-center justify-center px-2.5 gap-1.5 cursor-pointer transition-all duration-150';
btn.innerHTML =
'<span class="shrink-0" style="color:var(--ds-text-secondary)">' + DS_SPARKLE + '</span>' +
'<span style="font-size:var(--ds-font-xs);font-weight:500;color:var(--ds-text-secondary)">Ask AI</span>';
btn.addEventListener('click', function (e) {
e.preventDefault();
e.stopPropagation();
togglePanel();
});
searchBar.parentElement.appendChild(btn);
}
function injectMobileAskAIButton() {
if (document.getElementById('assistant-entry-mobile')) return;
var mobileSearch = document.getElementById('search-bar-entry-mobile');
if (!mobileSearch) return;
var btn = document.createElement('button');
btn.type = 'button';
btn.id = 'assistant-entry-mobile';
btn.setAttribute('aria-label', 'Ask AI');
btn.className = 'w-8 h-8 flex items-center justify-center cursor-pointer';
btn.style.color = 'var(--ds-text-secondary)';
btn.innerHTML = DS_SPARKLE;
btn.addEventListener('click', function (e) {
e.preventDefault();
e.stopPropagation();
togglePanel();
});
mobileSearch.parentElement.insertBefore(btn, mobileSearch);
}
var ASK_AI_ATTR = 'data-ask-ai-injected';
function injectCodeBlockAskAI() {
document.querySelectorAll('.code-block:not([' + ASK_AI_ATTR + '])').forEach(function (block) {
block.setAttribute(ASK_AI_ATTR, '1');
var container = block.querySelector('[data-floating-buttons="true"]');
if (!container) return;
var codeEl = block.querySelector('code');
var language = block.getAttribute('language') || (codeEl && codeEl.getAttribute('language')) || 'code';
var codeText = codeEl ? codeEl.textContent.trim() : '';
var wrapper = document.createElement('div');
wrapper.className = 'z-10 relative select-none';
var btn = document.createElement('button');
btn.className = 'h-[26px] w-[26px] flex items-center justify-center rounded-md backdrop-blur peer group/ask-code';
btn.setAttribute('aria-label', 'Ask AI about this code');
btn.style.color = 'var(--ds-text-tertiary)';
btn.innerHTML = DS_SPARKLE;
var tooltip = document.createElement('div');
tooltip.setAttribute('aria-hidden', 'true');
tooltip.className = 'absolute -top-3 left-1/2 transform whitespace-nowrap -translate-x-1/2 -translate-y-1/2 peer-hover:opacity-100 opacity-0 text-tooltip-foreground rounded-lg px-1.5 py-0.5 text-xs bg-primary-dark';
tooltip.textContent = 'Ask AI';
wrapper.appendChild(btn);
wrapper.appendChild(tooltip);
container.appendChild(wrapper);
btn.addEventListener('click', function (e) {
e.preventDefault();
e.stopPropagation();
var pageTitleEl = document.getElementById('page-title');
var pageTitle = (pageTitleEl ? pageTitleEl.textContent : document.title).trim();
var url = window.location.href;
var section = getNearestHeading(block);
var snippet = codeText.length > 800 ? codeText.substring(0, 800) + '\u2026' : codeText;
var msg =
'I\u2019m reading the ' + language + ' code example' +
(section ? ' in the \u201c' + section + '\u201d section' : '') +
' of \u201c' + pageTitle + '\u201d:\n' + url + '\n\n' +
'```' + language + '\n' + snippet + '\n```\n\n' +
'Can you help me understand how this works?';
pendingPreload = msg;
openPanel();
});
});
}
function injectPageAskAIButton() {
if (document.getElementById('page-ask-ai-btn')) return;
var pageTitle = document.getElementById('page-title');
var contextMenu = document.getElementById('page-context-menu');
if (!pageTitle || !contextMenu) return;
var btn = document.createElement('button');
btn.id = 'page-ask-ai-btn';
btn.type = 'button';
btn.className =
'rounded-xl px-3 py-1.5 cursor-pointer ' +
'text-gray-700 dark:text-gray-300 ' +
'border border-gray-200 dark:border-white/[0.07] ' +
'bg-background-light dark:bg-background-dark ' +
'hover:bg-gray-600/5 dark:hover:bg-gray-200/5 ' +
'transition-all duration-150';
btn.innerHTML =
'<div class="flex items-center gap-1.5 text-sm font-medium">' +
DS_SPARKLE + '<span>Ask AI</span></div>';
btn.addEventListener('click', function (e) {
e.preventDefault();
e.stopPropagation();
var title = pageTitle.textContent.trim();
var url = window.location.href;
var msg =
'I\u2019m reading \u201c' + title + '\u201d in the HoopAI Help Center.\n' +
'Page: ' + url + '\n\nI have a question about this page \u2014 can you help?';
pendingPreload = msg;
openPanel();
});
var column = document.createElement('div');
column.id = 'page-ask-ai-wrapper';
column.className = 'flex flex-col items-end gap-1 shrink-0 ml-auto sm:flex hidden';
contextMenu.parentElement.insertBefore(column, contextMenu);
column.appendChild(btn);
column.appendChild(contextMenu);
}
/* ── KEYBOARD SHORTCUT ───────────────────────────────────── */
document.addEventListener('keydown', function (e) {
if ((e.ctrlKey || e.metaKey) && e.key === 'i') {
e.preventDefault();
e.stopPropagation();
togglePanel();
}
});
/* ── BOOTSTRAP ───────────────────────────────────────────── */
function tryInject() {
injectAskAIButton();
injectMobileAskAIButton();
injectCodeBlockAskAI();
injectPageAskAIButton();
var missingDesktop = !document.getElementById('assistant-entry');
var missingMobile = !document.getElementById('assistant-entry-mobile');
if (missingDesktop || missingMobile) {
setTimeout(function () { injectAskAIButton(); injectMobileAskAIButton(); }, 500);
setTimeout(function () { injectAskAIButton(); injectMobileAskAIButton(); }, 1500);
setTimeout(function () { injectAskAIButton(); injectMobileAskAIButton(); }, 3000);
}
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', tryInject);
} else {
tryInject();
}
var observer = new MutationObserver(function () {
if (!document.getElementById('assistant-entry')) injectAskAIButton();
if (!document.getElementById('assistant-entry-mobile')) injectMobileAskAIButton();
if (!document.getElementById('page-ask-ai-btn')) injectPageAskAIButton();
injectCodeBlockAskAI();
});
observer.observe(document.documentElement, { childList: true, subtree: true });
})();
- Step 5.2 — Commit widget
git add theme/chat-widget.js
git commit -m "feat: replace VoiceGlow with custom Claude Haiku chat panel"
Task 6: Wire Worker URL and Verify docs.json
Files:-
Modify:
theme/chat-widget.js(replace placeholder URL) -
Verify:
docs.json(ensure JS path is correct) - Step 6.1 — Replace the WORKER_URL placeholder
theme/chat-widget.js, line 22, replace:
var WORKER_URL = 'https://hoopai-chat-api.REPLACE_WITH_ACCOUNT_ID.workers.dev';
var WORKER_URL = 'https://hoopai-chat-api.abc123.workers.dev';
- Step 6.2 — Verify docs.json JS reference
docs.json line ~143 references the correct path. Open docs.json and look for the "js" array. It should read:
"js": [
"theme/dropdown-icons.js",
"theme/method-icons.js",
"theme/chat-widget.js",
"theme/sidebar-toggle.js",
"theme/rtl-detect.js"
]
theme/ prefix (e.g. "chat-widget.js"), update them to include theme/:
# Check current value
grep -n '"chat-widget' docs.json
"theme/chat-widget.js", no change needed. If it shows "chat-widget.js", update it in docs.json.
- Step 6.3 — Commit URL + docs.json fix
git add theme/chat-widget.js docs.json
git commit -m "chore: wire Worker URL and fix docs.json JS path to theme/chat-widget.js"
Task 7: Integration Smoke Test
No file changes — verification only.- Step 7.1 — Kill any stale Node processes and start dev server
taskkill //F //IM node.exe
npx mintlify dev
localhost:3000 to be ready.
- Step 7.2 — Open browser at localhost:3000 and verify Ask AI button appears
http://localhost:3000. Confirm:
- Navbar shows the sparkle “Ask AI” button to the right of the search bar
- No JavaScript console errors
- Step 7.3 — Open panel and verify layout
- Panel slides in from the right
- Page content shifts left
- Empty state shows sparkle icon, title, and 3 suggestion chips
- Dark/light mode matches site theme (toggle dark mode to verify)
- Step 7.4 — Send a message and verify streaming
- User bubble appears immediately
- Typing indicator shows
- Response streams in token by token
- Send button shows stop icon during streaming
- Input is disabled during streaming
- After completion: send button resets, input re-enables
- Step 7.5 — Test expand and clear
- Step 7.6 — Test code block Ask AI button
/api-reference/introduction). Confirm:
- Sparkle icon appears in code block toolbar on hover
- Clicking it opens the panel with the code pre-loaded in input
- Step 7.7 — Commit final state if all checks pass
git add -A
git commit -m "feat: HoopAI AI chat widget complete — Claude Haiku via Cloudflare Worker"
Self-Review Checklist
- CORS security — Worker blocks all origins except
help.hoopai.comandlocalhost:3000 - API key never in browser —
OPENROUTER_API_KEYis a Wrangler secret, not in any file - Streaming — SSE handled with ReadableStream + TransformStream in Worker, ReadableStream in widget
- Tool loop — max 4 iterations prevents infinite loops; each tool fires
tool_start/tool_endSSE events - Web search gracefully disabled — if
BRAVE_SEARCH_API_KEYsecret absent, returns fallback message (no crash) - Support ticket gracefully disabled — if
SUPPORT_WEBHOOK_URLabsent, returns helpful message - Dark mode — all panel CSS uses
--ds-*tokens; no hardcoded colours - Mobile — panel goes full-width on
≤768px, no content push (avoids broken layout) - Existing Ask AI IDs preserved —
#assistant-entry,#assistant-entry-mobile,data-ask-ai-injected,#page-ask-ai-btnall identical - No VoiceGlow CDN calls —
cdn.voiceglow.orgscript removed entirely - Branding — system prompt explicitly blocks GoHighLevel / Marketing Muse / WhoPayI
- Keyboard shortcut — Ctrl+I / Cmd+I preserved