diff --git a/.env.example b/.env.example index dd69756b2..989e668ae 100644 --- a/.env.example +++ b/.env.example @@ -19,12 +19,6 @@ PUBLIC_KAPA_INTEGRATION_ID= # Live Docs Bot example: "Docs Widget" (type WIDGET). PUBLIC_KAPA_WEBSITE_WIDGET_INTEGRATION_ID= -# Optional Kapa project ID used to build internal conversation links in -# "Create ticket" email drafts: -# https://app.kapa.ai//conversations/ -# You can find this in the Kapa dashboard URL while viewing the project. -PUBLIC_KAPA_PROJECT_ID= - # RudderStack analytics — required for docs_404 event tracking and page analytics. # Without these, no analytics events are sent and the weekly 404 monitor will # report zero data. Set both in the Vercel project environment (all environments). @@ -37,12 +31,3 @@ PUBLIC_KAPA_PROJECT_ID= # directive in vercel.json (a mismatch silently blocks all events in the browser). PUBLIC_RUDDERSTACK_WRITE_KEY= PUBLIC_RUDDERSTACK_DATA_PLANE_URL= - -# Server-side handoff forwarding endpoint used by /api/support-handoff. -# This should point at the DevX service endpoint that creates Front -# conversations from docs chat handoff requests. -SUPPORT_HANDOFF_ENDPOINT_URL= - -# Required when SUPPORT_HANDOFF_ENDPOINT_URL is set. Docs forwards this only -# to the DevX service as the `?secret=` query parameter, not as a bearer token. -SUPPORT_HANDOFF_SHARED_SECRET= diff --git a/astro.config.mjs b/astro.config.mjs index c85dc0854..deb71f7ca 100644 --- a/astro.config.mjs +++ b/astro.config.mjs @@ -44,11 +44,6 @@ export default defineConfig({ access: 'public', optional: true, }), - PUBLIC_KAPA_PROJECT_ID: envField.string({ - context: 'client', - access: 'public', - optional: true, - }), PUBLIC_RUDDERSTACK_WRITE_KEY: envField.string({ context: 'client', access: 'public', @@ -64,16 +59,6 @@ export default defineConfig({ access: 'public', optional: true, }), - SUPPORT_HANDOFF_ENDPOINT_URL: envField.string({ - context: 'server', - access: 'secret', - optional: true, - }), - SUPPORT_HANDOFF_SHARED_SECRET: envField.string({ - context: 'server', - access: 'secret', - optional: true, - }), }, }, integrations: [ diff --git a/package.json b/package.json index e6a8c2735..18983cd39 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ "test:tracking-redirect": "node --test scripts/tracking-redirect.test.mjs", "test:tracking-routes": "node scripts/validate-tracking-routes.mjs", "test:kapa-webmcp": "node --test src/lib/kapa-webmcp.test.mjs", + "test:kapa-support-escalation": "node --test src/lib/kapa-support-escalation.test.mjs", "test:homepage-json-ld": "node scripts/validate-homepage-json-ld.mjs", "postinstall": "patch-package" }, diff --git a/src/components/KapaChatLauncher.css b/src/components/KapaChatLauncher.css index fd70b9fd4..9eeec47e3 100644 --- a/src/components/KapaChatLauncher.css +++ b/src/components/KapaChatLauncher.css @@ -638,50 +638,18 @@ gap: 0.5rem; margin-top: 0.875rem; } -.sl-kapa-feedback__handoff { - display: inline-flex; - align-items: center; - justify-content: center; - height: 2.25rem; - padding: 0 0.75rem; - border: 1px solid var(--sl-color-gray-5); - border-radius: var(--sl-radius-sm); - background: var(--sl-color-black); - color: var(--sl-color-gray-1); - font: inherit; - font-size: var(--sl-text-xs); - font-weight: 500; - cursor: pointer; - transition: - border-color 0.15s ease, - background-color 0.15s ease, - color 0.15s ease; -} - -.sl-kapa-feedback__handoff:hover { - border-color: var(--warp-control-border-hover); - background: var(--warp-control-bg-hover); - color: var(--sl-color-white); -} - -.sl-kapa-feedback__handoff:focus-visible { - outline: 2px solid var(--sl-color-accent-high); - outline-offset: 2px; -} -.sl-kapa-feedback__handoff--submit { - background: var(--sl-color-text-accent); - border-color: var(--sl-color-text-accent); - color: var(--sl-color-black); -} -.sl-kapa-feedback__handoff--submit:hover:not(:disabled) { - border-color: color-mix(in srgb, var(--sl-color-text-accent), var(--sl-color-white) 18%); - background: color-mix(in srgb, var(--sl-color-text-accent), var(--sl-color-white) 8%); - color: var(--sl-color-black); +.sl-kapa-support { + margin: 0.75rem 0 0; + padding-top: 0.75rem; + border-top: 1px solid var(--sl-color-gray-5); + color: var(--sl-color-gray-2); + font-size: var(--sl-text-xs); + line-height: 1.4; } -.sl-kapa-feedback__handoff--ghost { - background: transparent; +.sl-kapa-support a { + color: var(--sl-color-text-accent); } .sl-kapa-error { @@ -776,233 +744,12 @@ opacity: 0.5; cursor: not-allowed; } -.sl-kapa-handoff-inline { - margin-top: 0.75rem; - padding-top: 0.75rem; - border-top: 1px solid var(--sl-color-gray-5); - display: flex; - flex-direction: column; - gap: 0.5rem; -} - -.sl-kapa-handoff-inline label { - font-size: var(--sl-text-xs); - font-weight: 600; - color: var(--sl-color-gray-2); -} - -.sl-kapa-handoff-inline__row { - display: grid; - grid-template-columns: minmax(0, 1fr) auto auto; - gap: 0.5rem; -} - -.sl-kapa-handoff-inline input { - width: 100%; - min-width: 0; - height: 2.25rem; - border: 1px solid var(--sl-color-gray-5); - border-radius: var(--sl-radius-sm); - background: var(--sl-color-black); - color: var(--sl-color-white); - padding: 0 0.625rem; - font: inherit; - font-size: var(--sl-text-sm); -} - -.sl-kapa-handoff-inline input::placeholder { - color: var(--sl-color-gray-3); -} - -.sl-kapa-handoff-inline input:focus-visible { - outline: 2px solid var(--sl-color-accent-high); - outline-offset: 2px; -} - -.sl-kapa-handoff-inline__status { - margin: 0; - font-size: var(--sl-text-xs); - line-height: 1.4; -} - -.sl-kapa-handoff-inline__status--success { - color: #14532d; - background: rgb(220 252 231 / 0.9); - border: 1px solid rgb(74 222 128 / 0.7); - padding: 0.375rem 0.5rem; - border-radius: var(--sl-radius-sm); -} - -.sl-kapa-handoff-inline__status--error { - color: #b91c1c; - background: rgb(254 226 226 / 0.9); - border: 1px solid rgb(248 113 113 / 0.8); - padding: 0.375rem 0.5rem; - border-radius: var(--sl-radius-sm); -} - -.sl-kapa-handoff-inline__status a { - color: inherit; - text-decoration: underline; -} -@media (prefers-color-scheme: dark) { - .sl-kapa-handoff-inline__status--success { - color: #86efac; - background: rgb(20 83 45 / 0.35); - border-color: rgb(74 222 128 / 0.45); - } - .sl-kapa-handoff-inline__status--error { - color: #fecaca; - background: rgb(127 29 29 / 0.3); - border-color: rgb(248 113 113 / 0.5); - } -} - -.sl-kapa-handoff { - margin-top: 0.75rem; - display: flex; - flex-direction: column; - gap: 0.625rem; -} - -.sl-kapa-handoff__toggle { - display: inline-flex; - align-items: center; - justify-content: center; - gap: 0.5rem; - width: 100%; - height: 2.5rem; - padding: 0 0.875rem; - border: 1px solid var(--sl-color-gray-5); - border-radius: var(--sl-radius-sm); - background: var(--sl-color-black); - color: var(--sl-color-gray-1); - font: inherit; - font-size: var(--sl-text-sm); - font-weight: 500; - cursor: pointer; - transition: - border-color 0.15s ease, - background-color 0.15s ease, - color 0.15s ease; -} - -.sl-kapa-handoff__toggle svg { - width: 0.95rem; - height: 0.95rem; - flex: none; -} - -.sl-kapa-handoff__toggle:hover { - border-color: var(--warp-control-border-hover); - background: var(--warp-control-bg-hover); - color: var(--sl-color-white); -} - -.sl-kapa-handoff__toggle:focus-visible { - outline: 2px solid var(--sl-color-accent-high); - outline-offset: 2px; -} - -.sl-kapa-handoff__form { - display: flex; - flex-direction: column; - gap: 0.5rem; - padding: 0.75rem; - border: 1px solid var(--sl-color-gray-5); - border-radius: var(--sl-radius-lg); - background: color-mix(in srgb, var(--sl-color-black), var(--sl-color-gray-6) 28%); -} - -.sl-kapa-handoff__form label { - display: flex; - flex-direction: column; - gap: 0.25rem; -} - -.sl-kapa-handoff__form label span { - font-size: var(--sl-text-2xs); - color: var(--sl-color-gray-3); -} - -.sl-kapa-handoff__form input, -.sl-kapa-handoff__form textarea { - width: 100%; - border: 1px solid var(--sl-color-gray-5); - border-radius: var(--sl-radius-sm); - background: var(--sl-color-black); - color: var(--sl-color-white); - padding: 0.5rem 0.625rem; - font: inherit; - font-size: var(--sl-text-sm); -} - -.sl-kapa-handoff__form textarea { - resize: vertical; - min-height: 4rem; -} - -.sl-kapa-handoff__form input::placeholder, -.sl-kapa-handoff__form textarea::placeholder { - color: var(--sl-color-gray-3); -} - -.sl-kapa-handoff__form input:focus-visible, -.sl-kapa-handoff__form textarea:focus-visible { - outline: 2px solid var(--sl-color-accent-high); - outline-offset: 2px; -} - -.sl-kapa-handoff__submit { - margin-top: 0.25rem; - height: 2.5rem; - border: 1px solid var(--sl-color-text-accent); - border-radius: var(--sl-radius-sm); - background: var(--sl-color-text-accent); - color: var(--sl-color-black); - font: inherit; - font-size: var(--sl-text-sm); - font-weight: 600; - cursor: pointer; - transition: - border-color 0.15s ease, - background-color 0.15s ease; -} - -.sl-kapa-handoff__submit:hover:not(:disabled) { - border-color: color-mix(in srgb, var(--sl-color-text-accent), var(--sl-color-white) 18%); - background: color-mix(in srgb, var(--sl-color-text-accent), var(--sl-color-white) 8%); -} - -.sl-kapa-handoff__submit:focus-visible { - outline: 2px solid var(--sl-color-accent-high); - outline-offset: 2px; -} - -.sl-kapa-handoff__submit:disabled { - opacity: 0.55; - cursor: not-allowed; -} - -.sl-kapa-handoff__status { - margin: 0; - font-size: var(--sl-text-xs); - line-height: 1.4; -} - -.sl-kapa-handoff__status--success { - color: #86efac; -} - -.sl-kapa-handoff__status--error { - color: #fecaca; -} - -/* Hide the reCAPTCHA badge */ +/* Kapa's Chat SDK uses reCAPTCHA to protect chat and feedback requests. */ .grecaptcha-badge { visibility: hidden; } + .sl-kapa-meta { display: flex; align-items: center; @@ -1018,6 +765,7 @@ margin: 0; line-height: 1.4; } + .sl-kapa-disclosure-trigger { border: 0; padding: 0; @@ -1041,7 +789,6 @@ } .sl-kapa-attribution a, - .sl-kapa-disclosure a { color: inherit; } diff --git a/src/components/KapaChatLauncher.tsx b/src/components/KapaChatLauncher.tsx index 816f333a5..4c38cb72b 100644 --- a/src/components/KapaChatLauncher.tsx +++ b/src/components/KapaChatLauncher.tsx @@ -4,12 +4,11 @@ import { Children, isValidElement, useEffect, - useMemo, useRef, useState, } from 'react'; -import { CaptchaAction, KapaProvider, useCaptcha, useChat } from '@kapaai/react-sdk'; -import { PUBLIC_KAPA_INTEGRATION_ID, PUBLIC_KAPA_PROJECT_ID } from 'astro:env/client'; +import { KapaProvider, useChat } from '@kapaai/react-sdk'; +import { PUBLIC_KAPA_INTEGRATION_ID } from 'astro:env/client'; import { isMac, keymatch } from 'keymatch'; import ReactMarkdown from 'react-markdown'; import { @@ -26,14 +25,11 @@ import { LuX, } from 'react-icons/lu'; import './KapaChatLauncher.css'; +import { shouldShowSupportEscalation } from '../lib/kapa-support-escalation.js'; const integrationId = PUBLIC_KAPA_INTEGRATION_ID; -const projectId = PUBLIC_KAPA_PROJECT_ID?.trim() || ''; const title = 'Ask Warp'; const welcomeMessage = 'What do you want to know about Warp?'; -const uncertaintyThreshold = 0.15; -const conversationLengthThreshold = 3; -const emailPattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; const warpDocsMcpUrl = 'https://warp.mcp.kapa.ai'; const warpDocsMcpConfig = JSON.stringify( { @@ -47,58 +43,7 @@ const warpDocsMcpConfig = JSON.stringify( type FeedbackReaction = 'upvote' | 'downvote'; type McpCopyTarget = 'config' | 'url'; -type GenericRecord = Record; -type HandoffApiSuccess = { - message?: string; -}; - -function isObject(value: unknown): value is GenericRecord { - return typeof value === 'object' && value !== null; -} - -function readNumberField(record: GenericRecord, key: string) { - const value = record[key]; - if (typeof value === 'number' && Number.isFinite(value)) return value; - if (typeof value === 'string') { - const parsed = Number(value); - if (Number.isFinite(parsed)) return parsed; - } - return null; -} - -function readBooleanField(record: GenericRecord, key: string) { - const value = record[key]; - if (typeof value === 'boolean') return value; - if (typeof value === 'string') { - const normalized = value.trim().toLowerCase(); - if (normalized === 'true') return true; - if (normalized === 'false') return false; - } - return null; -} - -function getUncertaintyScore(metadata: unknown) { - if (!isObject(metadata)) return null; - return ( - readNumberField(metadata, 'uncertainty') ?? - readNumberField(metadata, 'uncertainty_score') ?? - readNumberField(metadata, 'uncertaintyScore') - ); -} - -function isAnswerUncertain(metadata: unknown) { - if (!isObject(metadata)) return false; - const flag = - readBooleanField(metadata, 'is_uncertain') ?? - readBooleanField(metadata, 'isUncertain'); - if (flag === true) return true; - const score = getUncertaintyScore(metadata); - return score !== null ? score >= uncertaintyThreshold : false; -} -function isValidEmailAddress(value: string) { - return emailPattern.test(value.trim()); -} function getChatErrorMessage(error: unknown) { const message = typeof error === 'string' ? error : String(error ?? ''); const normalized = message.toLowerCase(); @@ -529,13 +474,7 @@ function ChatSurface({ title, welcomeMessage, autoOpen = false, onNewConversatio const [query, setQuery] = useState(''); const [hasStartedConversation, setHasStartedConversation] = useState(false); const [isAppleDevice, setIsAppleDevice] = useState(false); - const [storedThreadId, setStoredThreadId] = useState(null); const [downvotedAnswerIds, setDownvotedAnswerIds] = useState>({}); - const [handoffEmailInput, setHandoffEmailInput] = useState(''); - const [handoffQaId, setHandoffQaId] = useState(null); - const [handoffErrorMessage, setHandoffErrorMessage] = useState(null); - const [handoffSuccessMessage, setHandoffSuccessMessage] = useState(null); - const [isSubmittingHandoff, setIsSubmittingHandoff] = useState(false); const [copiedMcpValue, setCopiedMcpValue] = useState(null); const messagesRef = useRef(null); const dialogRef = useRef(null); @@ -550,9 +489,7 @@ function ChatSurface({ title, welcomeMessage, autoOpen = false, onNewConversatio isGeneratingAnswer, isPreparingAnswer, submitQuery, - threadId, } = useChat(); - const { executeCaptcha } = useCaptcha(); useEffect(() => { setIsAppleDevice(isMac()); @@ -565,19 +502,6 @@ function ChatSurface({ title, welcomeMessage, autoOpen = false, onNewConversatio }; }, []); - useEffect(() => { - const savedThreadId = localStorage.getItem('warp_docs_kapa_thread_id'); - if (savedThreadId) { - setStoredThreadId(savedThreadId); - } - }, []); - - useEffect(() => { - if (!threadId) return; - setStoredThreadId(threadId); - localStorage.setItem('warp_docs_kapa_thread_id', threadId); - }, [threadId]); - useEffect(() => { if (!isOpen || !messagesRef.current) return; messagesRef.current.scrollTop = messagesRef.current.scrollHeight; @@ -589,23 +513,6 @@ function ChatSurface({ title, welcomeMessage, autoOpen = false, onNewConversatio messagesRef.current.scrollTop = messagesRef.current.scrollHeight; }, [conversation, isGeneratingAnswer, isOpen, isPreparingAnswer]); - useEffect(() => { - if (!handoffQaId || !isOpen) return; - const frame = window.requestAnimationFrame(() => { - const inlineForm = document.getElementById(`sl-kapa-handoff-inline-${handoffQaId}`); - if (!inlineForm) return; - inlineForm.scrollIntoView({ - behavior: 'smooth', - block: 'nearest', - }); - const emailInput = inlineForm.querySelector('input[type="email"]'); - emailInput?.focus(); - }); - return () => { - window.cancelAnimationFrame(frame); - }; - }, [handoffQaId, isOpen, conversation.length]); - useEffect(() => { const dialog = dialogRef.current; if (!dialog) return; @@ -658,7 +565,6 @@ function ChatSurface({ title, welcomeMessage, autoOpen = false, onNewConversatio const submit = () => { const value = query.trim(); if (!value || isBusy) return; - closeHandoffForm(); submitQuery(value); setHasStartedConversation(true); setQuery(''); @@ -681,114 +587,16 @@ function ChatSurface({ title, welcomeMessage, autoOpen = false, onNewConversatio return reaction === 'downvote' || downvotedAnswerIds[qaId] === true; }; - const shouldShowHandoffForAnswer = (qa: { + const shouldShowSupportForAnswer = (qa: { id?: string | null; answer?: string | null; - metadata?: unknown; reaction?: string | null; - }) => { - if (!qa.answer?.trim()) return false; - const conversationLengthTriggered = conversation.length >= conversationLengthThreshold; - const downvoteTriggered = isDownvoted(qa.id ?? null, qa.reaction ?? null); - const uncertaintyTriggered = isAnswerUncertain(qa.metadata); - return conversationLengthTriggered || downvoteTriggered || uncertaintyTriggered; - }; - - const buildConversationTranscript = () => { - if (!conversation.length) return 'No conversation history yet.'; - return conversation - .map((qa, index) => { - const sources = qa.sources?.length - ? `\nSources:\n${qa.sources.map((source) => `- ${source.title}: ${source.source_url}`).join('\n')}` - : ''; - return `Q${index + 1}: ${qa.question}\nA${index + 1}: ${qa.answer || '(no answer generated yet)'}${sources}`; - }) - .join('\n\n'); - }; - - const buildConversationLink = (currentThreadId: string | null) => { - if (!projectId || !currentThreadId) return null; - return `https://app.kapa.ai/${projectId}/conversations/${currentThreadId}`; - }; - - const openHandoffForm = (qaId: string) => { - setHandoffQaId(qaId); - setHandoffErrorMessage(null); - setHandoffSuccessMessage(null); - }; - - const closeHandoffForm = () => { - setHandoffQaId(null); - setHandoffErrorMessage(null); - setHandoffSuccessMessage(null); - }; - - const submitSupportHandoff = async (qa: { id?: string | null; question?: string }) => { - if (isSubmittingHandoff) return; - const userEmail = handoffEmailInput.trim().toLowerCase(); - if (!isValidEmailAddress(userEmail)) { - setHandoffErrorMessage('Enter a valid email address to continue.'); - return; - } - const localStorageThreadId = localStorage.getItem('warp_docs_kapa_thread_id'); - const activeThreadId = threadId || storedThreadId || localStorageThreadId; - if (!activeThreadId) { - setHandoffErrorMessage('Missing Kapa thread context; please send another message and try again.'); - return; - } - - const question = qa.question?.trim(); - if (!question) { - setHandoffErrorMessage('Missing question context for handoff.'); - return; - } - - const conversationLink = buildConversationLink(activeThreadId); - - setIsSubmittingHandoff(true); - setHandoffErrorMessage(null); - setHandoffSuccessMessage(null); - try { - let captcha; - try { - captcha = await executeCaptcha(CaptchaAction.FeedbackSubmit); - } catch { - setHandoffErrorMessage('Captcha verification could not be completed. Please try again.'); - return; - } - if (!captcha?.token || !captcha?.key) { - setHandoffErrorMessage('Captcha verification did not return a token. Please try again.'); - return; - } - const response = await fetch('/api/support-handoff', { - method: 'POST', - headers: { - 'content-type': 'application/json', - }, - body: JSON.stringify({ - user_email: userEmail, - question, - page_url: window.location.href, - conversation_transcript: buildConversationTranscript(), - kapa_project_id: projectId || null, - kapa_thread_id: activeThreadId, - kapa_conversation_url: conversationLink || null, - captcha_token: captcha.token, - captcha_header: captcha.key, - }), - }); - const payload: HandoffApiSuccess & { message?: string } = await response.json().catch(() => ({})); - if (!response.ok) { - setHandoffErrorMessage(payload.message || 'Could not create support ticket. Please try again.'); - return; - } - setHandoffSuccessMessage(payload.message || 'Support ticket created successfully.'); - } catch { - setHandoffErrorMessage('Could not reach support handoff service. Please try again.'); - } finally { - setIsSubmittingHandoff(false); - } - }; + }) => + shouldShowSupportEscalation({ + answer: qa.answer, + conversationLength: conversation.length, + isDownvoted: isDownvoted(qa.id ?? null, qa.reaction ?? null), + }); const openPanel = () => { // If the Pagefind search dialog is open with a query typed in, hand @@ -1084,68 +892,17 @@ function ChatSurface({ title, welcomeMessage, autoOpen = false, onNewConversatio >