/home/techb158/primomovers.ca/wp-content/plugins/everest-forms/src/builder-ai
NameSizeModeActions
BuilderAIChat.tsx352610644editdlrm
index.tsx7050644editdlrm
Edit: /home/techb158/primomovers.ca/wp-content/plugins/everest-forms/src/builder-ai/BuilderAIChat.tsx (35261B)
import React, { useEffect, useRef, useState } from 'react'; import { LuSparkles, LuX, LuSend } from 'react-icons/lu'; // ── Types ───────────────────────────────────────────────────────────────────── interface Message { role: 'user' | 'assistant'; text: string; loading?: boolean; notice?: boolean; noticeUrl?: string; reload?: boolean; } // Edit-form prompt suggestions shown inside the chat panel. const EDIT_SUGGESTIONS = [ 'Add a phone number field', 'Make all fields required', 'Add a date picker field', 'Insert a file upload field', 'Add a dropdown with Yes / No options', 'Remove the last field', 'Add an address section', 'Insert a multi-line text field', ]; // Subtle intro message from the AI assistant. const GREETING = "Hi! I'm your AI form assistant. Tell me how to improve this form — or pick a suggestion below."; // Discovery hint for anyone who hasn't opened the AI Form Assistant before — shown once, // dismissed forever (per-user, via EVF_AI_Ajax::dismiss_hint()) either by closing it or // by actually opening the panel. const AI_HINT_NAME = 'form'; const UPGRADE_URL = 'https://everestforms.net/upgrade/?utm_source=evf-free&utm_medium=ai-chat&utm_campaign=daily-limit&utm_content=Upgrade+to+Pro'; // Builder context (form id + nonce) localized by class-evf-admin-assets.php. interface BuilderAIConfig { ajaxUrl?: string; nonce?: string; formId?: number; formTitle?: string; aiDisabled?: boolean; hintDismissed?: boolean; } const cfg: BuilderAIConfig = ( window as any ).evfBuilderAI || {}; // On local / development sites the AI gateway is unavailable — the assistant is // shown but disabled (greyed trigger, opens nothing, explains why on hover). const AI_DISABLED = !! cfg.aiDisabled; // Daily-request usage snapshot the gateway now returns on every AI response. interface UsageInfo { remaining: number; limit: number; used: number; } // At/below this many remaining requests the count switches to a gentle amber warning. const USAGE_LOW_THRESHOLD = 3; // "7 requests left today" — pluralized. const usageLabel = ( n: number ): string => `${ n } request${ 1 === n ? '' : 's' } left today`; // Read the { remaining, limit, used } usage object off a raw AI response, or null. const readUsage = ( raw: any ): UsageInfo | null => { const u = raw && raw.usage; if ( u && 'number' === typeof u.remaining ) { return { remaining: u.remaining, limit: u.limit, used: u.used }; } return null; }; // Full tooltip text for the credits pill. const usageTooltip = ( usage: UsageInfo ): string => 'number' === typeof usage.limit && usage.limit > 0 ? `${ usage.remaining } of ${ usage.limit } AI requests left today · resets daily` : usageLabel( usage.remaining ); /** * The daily-request "credits" pill for the panel header — a sparkle, an `18/20` count, and a * slim meter that drains as requests are used. Translucent white on the purple header; turns * amber when the count runs low or is exhausted. */ const UsagePill: React.FC<{ usage: UsageInfo | null; loading?: boolean }> = ( { usage, loading } ) => { if ( ! usage ) { if ( ! loading ) return null; // Skeleton — holds the pill's place while the first count loads. return (
); } const hasLimit = 'number' === typeof usage.limit && usage.limit > 0; const { remaining } = usage; const amber = remaining <= USAGE_LOW_THRESHOLD; const frac = hasLimit ? Math.max( 0, Math.min( 1, remaining / usage.limit ) ) : 1; const numColor = amber ? '#7a4b00' : '#fff'; const denColor = amber ? 'rgba(122,75,0,.7)' : 'rgba(255,255,255,.7)'; return (
{ remaining } { hasLimit && /{ usage.limit } } { hasLimit && ( ) }
); }; // Edit the current builder form via the ThemeGrill AI Cloud (Python) gateway. const editFormViaAi = async ( instruction: string, ): Promise<{ ok: boolean; message: string; isNotice?: boolean; noticeUrl?: string; needsReload?: boolean; limitReached?: boolean; limitTier?: string; usage?: UsageInfo | null; }> => { if ( ! cfg.ajaxUrl || ! cfg.nonce || ! cfg.formId ) { return { ok: false, message: 'AI assistant is unavailable on this screen.' }; } const body = new URLSearchParams(); body.append( 'action', 'evf_ai_update_form' ); body.append( 'nonce', cfg.nonce ); body.append( 'form_id', String( cfg.formId ) ); body.append( 'prompt', cfg.formTitle || 'Edit this form' ); body.append( 'refine_prompt', instruction ); try { const resp = await fetch( cfg.ajaxUrl, { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: body.toString(), } ); const json = await resp.json(); if ( json?.success ) { return { ok: true, message: json?.data?.notice || "Done — I've updated your form. Refreshing the canvas…", isNotice: !! json?.data?.notice, noticeUrl: json?.data?.notice_url || '', needsReload: !! json?.data?.needs_reload, usage: readUsage( json?.data ), }; } // "daily_limit_reached" is today's hard cap (Free AND Pro both have one, Pro's is far // higher) — worth a persistent "you're blocked until tomorrow" state. A plain // "rate_limit" (transient IP throttle) isn't — that's just a normal error to retry. const isLimit = json?.data?.code === 'daily_limit_reached'; const tier = json?.data?.tier || 'free'; return { ok: false, message: json?.data?.message || 'Sorry, I could not update the form. Please try again.', limitReached: isLimit, limitTier: tier, // Match Style with AI: the daily-limit message carries the "Upgrade to Pro" link // inline in the chat bubble (Free tier only — a Pro user who hit their own, higher // cap gets no upsell). Previously the link only appeared in the button tooltip. noticeUrl: isLimit && 'pro' !== tier ? UPGRADE_URL : '', usage: readUsage( json?.data ), }; } catch { return { ok: false, message: 'Could not reach the AI service. Please try again.' }; } }; // ── Component ───────────────────────────────────────────────────────────────── const BuilderAIChat: React.FC = () => { const [open, setOpen] = useState(false); const [input, setInput] = useState(''); const [messages, setMessages] = useState([ { role: 'assistant', text: GREETING }, ]); const [loading, setLoading] = useState(false); // Daily-request usage snapshot — drives the header credits pill. Seeded on mount so it's // visible the moment the panel opens, then refreshed from every response. const [usage, setUsage] = useState(null); const [usageLoading, setUsageLoading] = useState(true); const [buttonHovered, setButtonHovered] = useState(false); const [tooltipHovered, setTooltipHovered] = useState(false); const [rateLimited, setRateLimited] = useState(false); // Which tier hit the daily cap — Pro has its own (much higher) limit too, and shouldn't // be told to "upgrade to Pro" when it's already on it. const [limitTier, setLimitTier] = useState('free'); const showTooltip = !open && (buttonHovered || tooltipHovered); const [hintDismissed, setHintDismissed] = useState(!!cfg.hintDismissed); const dismissHint = () => { if (hintDismissed) return; setHintDismissed(true); if (!cfg.ajaxUrl) return; const body = new URLSearchParams(); body.append('action', 'evf_ai_dismiss_hint'); body.append('hint', AI_HINT_NAME); body.append('nonce', cfg.nonce || ''); fetch(cfg.ajaxUrl, { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: body.toString(), }).catch(() => { // Best-effort only — worst case the hint reappears next visit. }); }; const showHint = !open && !hintDismissed && !AI_DISABLED; // The builder shell shows its own loading overlay (`.everest-forms-overlay`, faded out on // window `load` — see form-builder.js) while fields/canvas are still booting. Stay hidden // until then so this floating button doesn't sit on top of that loading screen. const [builderLoaded, setBuilderLoaded] = useState(() => document.readyState === 'complete'); useEffect(() => { if (builderLoaded) return; const onLoad = () => setBuilderLoaded(true); window.addEventListener('load', onLoad); return () => window.removeEventListener('load', onLoad); }, [builderLoaded]); // Read the customizer button's actual CSS bottom so we stack correctly even // in multi-part mode (where the customizer moves up to 62px). Falls back to // null when the addon is not active — AI button then sits at bottom: 22px. const [customizerBottom, setCustomizerBottom] = useState(null); // Multi-Part's own "Add New Part" tab bar pins itself to the same bottom-right corner this // button defaults to when the Style Customizer addon isn't installed/active (so there's no // `.everest-forms-designer-icon` to measure from) — without this, the two overlap (EVF-2736). // Measured the same way as customizerBottom (from the real element's edge, not a guessed // constant) since the bar's own height varies with its content/viewport. const [multiPartBarBottom, setMultiPartBarBottom] = useState(null); // Show the assistant only on the Builder (Fields) tab — mirror the Style // Customizer button, which lives inside the Fields panel and is hidden when // other tabs (Settings, Integrations, …) are active. const [onBuilderTab, setOnBuilderTab] = useState(true); const messagesEndRef = useRef(null); const inputRef = useRef(null); useEffect(() => { const builder = document.getElementById('everest-forms-builder'); const read = () => { const el = document.querySelector('.everest-forms-designer-icon'); if (el) { const v = parseInt(window.getComputedStyle(el).bottom, 10); setCustomizerBottom(isNaN(v) ? null : v); } else { setCustomizerBottom(null); } const bar = document.querySelector('.everest-forms-multi-part-tabs'); if (bar && builder?.classList.contains('multi-part-activated')) { setMultiPartBarBottom(Math.round(window.innerHeight - bar.getBoundingClientRect().top)); } else { setMultiPartBarBottom(null); } }; read(); // Re-read when builder classes change (multi-part toggle adds/removes class); polling // alongside as a safety net for the same class of DOM-rewrite edge case noted below. const observer = new MutationObserver(read); if (builder) observer.observe(builder, { attributes: true, attributeFilter: ['class'] }); const interval = window.setInterval(read, 500); return () => { observer.disconnect(); window.clearInterval(interval); }; }, []); // Track the active builder tab. Switching tabs toggles the `active` class on the Fields // panel; we only render the assistant while that panel is active. Re-query the panel INSIDE // read() rather than capturing it once — Multi-Part's own tab-rebuild JS (triggered when it's // enabled from Settings) can replace that DOM node entirely, which would otherwise leave a // MutationObserver watching a detached element and freeze `onBuilderTab` forever (EVF-2736: // the assistant never comes back after Settings → enable Multi-Part → Fields). Observing the // stable builder root with `subtree: true` catches that swap either direction — but a // half-second poll runs alongside it as a safety net, since some jQuery-driven DOM rewrites // (full innerHTML replacement outside a single attribute mutation, timing around the rebuild) // have still been reported to slip past the observer in the wild; polling can't miss. useEffect(() => { const root = document.getElementById('everest-forms-builder') || document.body; const read = () => { const panel = document.getElementById('everest-forms-panel-fields'); setOnBuilderTab(panel ? panel.classList.contains('active') : true); }; read(); const observer = new MutationObserver(read); observer.observe(root, { attributes: true, attributeFilter: ['class'], subtree: true, childList: true }); const interval = window.setInterval(read, 500); return () => { observer.disconnect(); window.clearInterval(interval); }; }, []); // Close the chat panel when navigating away from the Builder tab. useEffect(() => { if (!onBuilderTab) setOpen(false); }, [onBuilderTab]); // Auto-scroll to latest message. useEffect(() => { if (open) messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); }, [messages, open]); // Focus input when panel opens. useEffect(() => { if (open) setTimeout(() => inputRef.current?.focus(), 120); }, [open]); // Seed the header credits pill on mount so the count is ready as soon as the panel opens. // Best-effort — on a disabled (local) or unregistered site we skip the call and show no pill. useEffect(() => { if (AI_DISABLED || !cfg.ajaxUrl || !cfg.nonce) { setUsageLoading(false); return; } let cancelled = false; const body = new URLSearchParams(); body.append('action', 'evf_ai_get_usage'); body.append('nonce', cfg.nonce); fetch(cfg.ajaxUrl, { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: body.toString(), }) .then((r) => r.json()) .then((j) => { if (cancelled) return; const u = readUsage(j?.data); if (u) setUsage(u); }) .catch(() => {}) .finally(() => { if (!cancelled) setUsageLoading(false); }); return () => { cancelled = true; }; // eslint-disable-next-line react-hooks/exhaustive-deps }, []); const sendMessage = async (text: string) => { if (!text.trim() || loading) return; const userText = text.trim(); setInput(''); setMessages(prev => [...prev, { role: 'user', text: userText }]); setLoading(true); setMessages(prev => [...prev, { role: 'assistant', text: '', loading: true }]); const result = await editFormViaAi(userText); // Keep the header's "N requests left today" chip in sync. if (result.usage) setUsage(result.usage); // Track rate limit so the trigger button tooltip updates. if (!result.ok && result.limitReached) { setRateLimited(true); setLimitTier(result.limitTier || 'free'); } // Show the notice at most once per chat session. if (result.isNotice && messages.some(m => m.notice)) { result.isNotice = false; result.noticeUrl = ''; result.message = "Done — I've updated your form. Refreshing the canvas…"; } // When settings changed (redirect, email, message, etc.) set a clean done text. // The reload link is rendered inside the bubble; no auto-reload happens. if (result.ok && result.needsReload && !result.isNotice) { result.message = "Done — your form settings have been updated."; } setMessages(prev => { const copy = [...prev]; const last = copy[copy.length - 1]; if (!last?.loading) return copy; if (result.ok && result.isNotice) { // Edit succeeded but there's a Pro/addon notice — show "Done" first, // then a separate notice bubble below so the user knows the edit applied. copy[copy.length - 1] = { role: 'assistant', text: result.needsReload ? "Done — your form settings have been updated." : "Done — I've updated your form. Refreshing the canvas…", }; copy.push({ role: 'assistant', text: result.message, notice: true, noticeUrl: result.noticeUrl || '', reload: !! result.needsReload, }); } else { copy[copy.length - 1] = { role: 'assistant', text: result.message, notice: result.isNotice || ! result.ok, noticeUrl: result.noticeUrl || '', reload: result.ok && !! result.needsReload, }; } return copy; }); setLoading(false); if (result.ok) { // Re-open the panel if the user collapsed it while the request was processing, so the // success confirmation (and any Pro/reload notice) is visible now that it's done. setOpen(true); const w = window as any; if (result.needsReload) { // Settings changed — don't auto-reload; the bubble shows a manual // "Refresh the page" link so the user can reload when ready. } else if (typeof w.evfReloadBuilderFields === 'function' && cfg.formId && cfg.nonce) { w.evfReloadBuilderFields(cfg.formId, cfg.nonce, () => {}); } else { setTimeout(() => window.location.reload(), 1500); } } }; const handleKeyDown = (e: React.KeyboardEvent) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendMessage(input); } }; // Bottom offset of the trigger button. // When the customizer is active we sit 8px above its top edge; otherwise, if Multi-Part's // "Add New Part" bar occupies the usual 22px corner instead, we sit 8px above ITS top edge; // otherwise we share the same plain bottom baseline (22px). const BTN_SIZE = 55; const BTN_RIGHT = 22; const BASE_BOTTOM = multiPartBarBottom !== null ? multiPartBarBottom + 8 : 22; const BTN_BOTTOM = customizerBottom !== null ? customizerBottom + BTN_SIZE + 8 : BASE_BOTTOM; // Modal sits 8px above the top edge of the trigger button. const MODAL_BOTTOM = BTN_BOTTOM + BTN_SIZE + 8; // ── Render ────────────────────────────────────────────────────────────── // Hidden outside the Builder (Fields) tab. if (!onBuilderTab || !builderLoaded) return null; return ( <> {/* ── Floating trigger button (always rendered) ──────────────────── Shows sparkles when closed, X when open. zIndex sits above the chat panel so it's always clickable. ── */} {/* ── Processing ring — spins around the closed trigger while a request is in flight, so the user knows the AI is still working with the panel collapsed. ── */} {loading && !open && !AI_DISABLED && (