// Axiomic landing demo chat (Commit C of feat/demo-chat-foundation). // // Two modes, automatic fallback: // * "llm" — backend SSE stream (Anthropic Sonnet via Commit B). Token // cap, judge moderation, per-IP / global daily budget enforced // server-side. Lead form rendered from a server-emitted // ``lead_form`` event (NOT raw model HTML). // * "regex" — in-page BOT_SCRIPT (the canned conversation we shipped // before the LLM backend existed). Used as fallback when // /start returns 503 (demo_chat_disabled / budget) or 403 // (turnstile_failed). Also used per-turn within "llm" mode // when a /message call fails or the conv is killed. // // Session persistence // ------------------- // We persist a small marker in ``localStorage["axiomic_demo_chat_session_v1"]`` // so a refresh inside the same browser tab doesn't burn the per-IP quota // on a fresh /start. The actual session_id + conv_id are stored on the // signed-cookie session by the backend; localStorage just carries the // FACT that we have a server-side session so the UI can resume optimally. // The marker is CLEARED when: // * /message returns 410 conv_killed (judge or daily-cost gate) // * lead capture form succeeds (conversation is logically "done") // // Turnstile // --------- // Invisible widget — siteverify only fires AFTER the user actually types // something. Lazy ``turnstile.execute()`` produces the token that goes in // the /start body. Dev short-circuits when the backend secret is the CF // official ``1x00…00AA`` test pair (see public_demo_chat._verify_turnstile). const { useState, useEffect, useRef, useCallback } = React; const STORAGE_KEY = "axiomic_demo_chat_session_v1"; // TTL for the persisted "regex" downgrade marker. localStorage has no // expiration, so a transient backend outage on day N would otherwise // permanently downgrade the visitor to scripted-bot mode on day N+1 // (until they manually clear storage). 6 hours is long enough to ride // out a real outage within a session, short enough to recover on the // next-day visit when the backend is healthy again. const MODE_REGEX_TTL_MS = 6 * 60 * 60 * 1000; // Read the persisted mode marker, honouring the 6h TTL. Returns "regex" // only when a fresh, non-expired marker is present; any malformed / // missing / expired value yields "" (caller defaults to optimistic LLM). // // Storage shape is the JSON ``{mode: "regex", expiresAt: }``. // We tolerate the legacy bare-string format ("regex") by treating it as // expired — the next time we set it, we'll write the new shape. function _readPersistedMode() { try { const raw = localStorage.getItem(STORAGE_KEY); if (!raw) return ""; // Fast-path: if it doesn't start with `{`, it's the legacy bare // string. Treat as expired so we don't trust it across days. if (raw[0] !== "{") return ""; const parsed = JSON.parse(raw); if (!parsed || typeof parsed !== "object") return ""; if (parsed.mode !== "regex") return ""; const exp = typeof parsed.expiresAt === "number" ? parsed.expiresAt : 0; if (Date.now() >= exp) { // Expired — drop it so we start fresh in LLM mode this visit. try { localStorage.removeItem(STORAGE_KEY); } catch (_e) { /* ignore */ } return ""; } return "regex"; } catch (_e) { return ""; } } const BOT_SCRIPT = [ { match: /(здравств|привет|добр|hi|hello)/i, reply: [ "Здравствуйте! Я — AI-консультант Axiomic. Расскажу, как мы помогаем строительным компаниям не терять заявки.", "С чего удобнее начать: как это работает, сколько стоит, или что получаете через месяц?", ], }, { match: /(цен|стоим|тариф|почём|сколько стои|сколько это)/i, reply: [ "20 000 ₽ / месяц. Одна подписка, без доплат за диалоги и каналы.", "Первая неделя — бесплатно. Подключаем мессенджеры, грузим ваши документы, настраиваем воронку. Работаете 7 дней в продакшене, дальше решаете.", "Для сравнения: один менеджер с окладом + KPI + налоги выходит в 90–130 тыс ₽/мес и не работает ночью.", ], }, { match: /(проб|бесплат|тест|демо|триал|trial)/i, reply: [ "Да, неделя бесплатно, в продакшене. Без карты.", "Подключаем Telegram, ВКонтакте, Авито и MAX. Загружаем ваш прайс и каталог в базу знаний. С первого дня AI отвечает клиентам и создаёт лиды в Битрикс24.", "Через 7 дней в кабинете увидите, сколько диалогов закрыли без менеджера и сколько лидов ушло в CRM.", ], }, { match: /(выгод|оkупае|окупае|экономи|прибыл|деньг|roi)/i, reply: [ "Считаем по-строительному, в деньгах:", "Если у вас сейчас приходит 200 заявок/мес и менеджеры успевают обработать 60% — это 80 упущенных лидов. При конверсии в сделку 5% и среднем чеке 3 млн ₽ — это 12 млн ₽ упущенной выручки. В месяц.", "Axiomic отвечает на 100% заявок за 3 секунды. Даже если вернуть треть упущенных — подписка окупается десятки раз.", ], }, { match: /(как работа|как устро|принцип|схем)/i, reply: [ "Пять шагов, без участия менеджера до последнего:", "1. Клиент пишет в Telegram / ВК / Авито — AI отвечает за 1–3 сек.\n2. Уточняет: метраж, регион, сроки, бюджет.\n3. Цитирует ваш прайс и каталог (RAG — не выдумывает).\n4. Квалифицирует по вашим критериям.\n5. Создаёт сделку в Битрикс24 с полной историей диалога.", "Менеджер открывает Битрикс утром — там горячие лиды, готовые к звонку.", ], }, { match: /(срок|быстр|когда|внедр|запуск|неделю|дней)/i, reply: [ "7 рабочих дней от договора до первого диалога в продакшене.", "День 1–2: подключаем мессенджеры и Битрикс24 (webhook за 10 минут).\nДень 3–4: загружаем ваши прайсы, каталоги, регламенты в базу знаний.\nДень 5–6: настраиваем воронку и сценарии под вашу нишу.\nДень 7: старт в боевом режиме.", "Без кода. Без ваших разработчиков. Без интегратора за 500 000 ₽.", ], }, { match: /(документ|прайс|база знан|rag|каталог|обуч)/i, reply: [ "Загружаете PDF, Excel, Word — прайсы, каталоги, регламенты, типовые проекты.", "Сейчас в среднем клиенте: 796 документов, 2 категории, 639 тыс символов в индексе. AI находит точную строку в вашем прайсе и цитирует — не округляет, не выдумывает цены.", "Обновили прайс — заменили файл в кабинете. Через 2 минуты AI уже отвечает по новым ценам.", ], }, { match: /(канал|мессендж|telegram|whatsapp|авито|вконтакт|вк|max)/i, reply: [ "Четыре канала из коробки: Telegram, ВКонтакте, Авито, MAX.", "Все диалоги в одном инбоксе в кабинете. Одна база знаний, одна воронка, одна аналитика.", "WhatsApp и amoCRM — в разработке, Q3 2026.", ], }, { match: /(замен|увол|менеджер|человек|сотрудн)/i, reply: [ "Нет. Axiomic закрывает первую линию: мгновенный ответ, квалификация, передача тёплых в CRM.", "Ваши менеджеры перестают тратить день на «а сколько стоит?» и «а вы делаете фундамент?». Получают готовых к разговору клиентов.", "По запросу или ключевой фразе AI молча передаёт диалог оператору — клиент не видит «перехода».", ], }, { match: /(битрикс|crm|сделк|лид|amocrm)/i, reply: [ "Битрикс24 — из коробки. Подключение через webhook, 10 минут.", "Когда AI квалифицировал клиента — создаётся сделка с заполненными полями, тегами и полной историей диалога. Менеджер открывает Битрикс — видит готового клиента с контекстом.", "Зеркалирование не требуется: если у вас подключён Консультант через Открытые линии, AI-ответы уже видны оператору в чате.", ], }, { match: /(безопас|данн|152|ndа|nda|конфид)/i, reply: [ "Серверы в РФ, шифрование TLS + AES-256. В реестре операторов ПДн Роскомнадзора (152-ФЗ).", "Диалоги не используются для обучения сторонних моделей. По запросу — NDA и аудит безопасности перед подписанием.", ], }, { match: /(боится|поймёт|поймет|палит|спалит|бот|робот)/i, reply: [ "Axiomic не притворяется человеком — это против закона о рекламе.", "Но тон и темп не вызывают отторжения: задержка перед ответом, «печатает…», живые формулировки, опечатки опционально. По данным пилотных клиентов — до 72% клиентов доходят до звонка, не задав «это бот?».", ], }, { match: /(ноч|выходн|круглосуточ|24|7)/i, reply: [ "24 / 7. Без выходных, без отпусков, без больничных.", "По статистике — 40% заявок в стройке приходят после 19:00 и по субботам. Сейчас эти клиенты уходят к тому, кто ответил. С Axiomic — отвечаете вы.", ], }, { match: /(рабоч|час|график)/i, reply: [ "Можно настроить: например, «AI консультирует 24/7, но о передаче менеджеру пишет: свяжется в рабочие часы 9:00–18:00 МСК».", "Клиент получает ответ мгновенно. Менеджер — уведомление в Telegram в своё рабочее время.", ], }, ]; const FALLBACK = [ "Хороший вопрос — сразу свяжу вас с человеком, ответит точнее.", "Заодно: что вам сейчас важнее понять — цена, сроки внедрения или как это окупится?", ]; const INITIAL_MESSAGES = [ { who: "bot", text: "Здравствуйте! Я — AI-консультант Axiomic. Помогаю строительным компаниям не терять заявки, которые приходят ночью, в выходные и с Авито.", time: "14:02", }, { who: "bot", text: "Могу рассказать про цены, сроки запуска или как это окупается. Или спросите своими словами — отвечу.", time: "14:02", }, ]; const SUGGESTIONS = [ "Сколько стоит?", "Есть бесплатный период?", "Как быстро можно запустить?", "Как это окупится?", ]; function findReply(text) { for (const entry of BOT_SCRIPT) { if (entry.match.test(text)) return entry.reply; } return FALLBACK; } function fmtTime() { const d = new Date(); return `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`; } // ── Turnstile invisible widget ───────────────────────────────────────────── // // Loaded by the host page (index.html). We render it lazily on first user // input so a passive landing visitor never burns a CF rate slot. The // official always-passes test sitekey is the default (set by host page); // production deploys overwrite ``window.CF_TURNSTILE_SITE_KEY``. // // Invisibility is controlled by the SITEKEY mode in the CF dashboard // (NOT by a `size` param — CF rejects the literal value 'invisible' with // "expected compact, flexible, or normal"). We hide the host container // via inline CSS instead, so even a "managed" sitekey stays out of the // visitor's way for the demo widget. // // ``execute()`` returns a token via the `callback` we registered at render // time. A second concurrent caller while a token is in flight piggybacks // on the in-flight promise (single shared resolver) — preventing the // previous "second call overwrites first resolver, first promise leaks // forever" race. const TURNSTILE_RENDER_HOST_ID = "axiomic-chat-turnstile"; let _turnstileWidgetId = null; // Single shared in-flight promise. While non-null, all concurrent callers // of getTurnstileToken() await the same resolution. Set to null on // callback / error / timeout so the next call triggers a fresh execute(). let _turnstileInFlight = null; let _turnstileInFlightResolve = null; function _resolveInFlight(token) { if (_turnstileInFlightResolve) { const r = _turnstileInFlightResolve; _turnstileInFlight = null; _turnstileInFlightResolve = null; r(token); } } function _ensureTurnstileHost() { if (document.getElementById(TURNSTILE_RENDER_HOST_ID)) return; const div = document.createElement("div"); div.id = TURNSTILE_RENDER_HOST_ID; // Off-screen rather than display:none — some Turnstile modes require // the host to participate in layout for the iframe to mount. div.style.position = "absolute"; div.style.left = "-9999px"; div.style.top = "-9999px"; div.style.width = "1px"; div.style.height = "1px"; div.style.overflow = "hidden"; document.body.appendChild(div); } function _renderTurnstileOnce() { if (_turnstileWidgetId !== null) return; if (typeof window === "undefined" || !window.turnstile) return; _ensureTurnstileHost(); const siteKey = window.CF_TURNSTILE_SITE_KEY || "1x00000000000000000000AA"; try { _turnstileWidgetId = window.turnstile.render(`#${TURNSTILE_RENDER_HOST_ID}`, { sitekey: siteKey, // No `size` — invisibility is configured per-sitekey in CF dashboard. // Passing the rejected value here would make CF reject render(). callback: (token) => _resolveInFlight(token || ""), "error-callback": () => _resolveInFlight(""), "timeout-callback": () => _resolveInFlight(""), }); } catch (_err) { _turnstileWidgetId = null; } } async function getTurnstileToken() { // Try to render the widget — if Turnstile script never loaded, return // empty token; backend will fail-close to ``fallback: regex`` and the // chat continues in script mode. _renderTurnstileOnce(); if (_turnstileWidgetId === null || !window.turnstile) return ""; // Fast-path: invisible Turnstile auto-executes on render() (~620ms // after script load). If a fresh token is already sitting in the // widget (auto-execute completed before user clicked, or a prior // execute() resolved without our callback being wired in time), // return it directly without re-executing. Tokens are valid ~5min // per CF docs and the widget rotates them as needed, so a stale // token from the same page-load session is acceptable here. try { if (typeof window.turnstile.getResponse === "function") { const existing = window.turnstile.getResponse(_turnstileWidgetId); if (existing && typeof existing === "string" && existing.length > 0) { return existing; } } } catch (_getRespErr) { // getResponse can throw if the widget isn't fully rendered yet; // fall through to the execute() path. } // Coalesce concurrent callers onto a single in-flight promise so a // second call can never overwrite the first resolver (which would // leak the first promise forever). if (_turnstileInFlight) return _turnstileInFlight; // Track whether the resolver fired synchronously inside the executor // (via _resolveInFlight from a sync reset/execute callback or our // own catch path). If it did, the promise is already settled to "" // and we must NOT publish it to _turnstileInFlight — doing so would // make every subsequent caller hit the cached "" instead of // triggering a fresh execute(). let settledSync = false; const promise = new Promise((resolve) => { // Wrap resolve so we can both detect sync settlement and clear // the module-level resolver atomically. const wrappedResolve = (token) => { settledSync = true; resolve(token); }; // Clear any prior in-flight widget state before re-executing. // CF emits "Call to execute() on a widget that is already executing" // when a previous execute() hasn't resolved (e.g., page-load // auto-execute still pending → user clicks chip → execute fires // again on same widget). reset() is idempotent per CF docs. // // IMPORTANT: reset() can synchronously fire error-callback / // timeout-callback on the widget. If we'd already assigned // _turnstileInFlightResolve = wrappedResolve, that callback would // null our resolver before execute() runs → caller hangs until // the 6s safety timeout. So we reset FIRST, then publish the // resolver to the module scope, then execute(). try { window.turnstile.reset(_turnstileWidgetId); } catch (_resetErr) { /* ignore */ } _turnstileInFlightResolve = wrappedResolve; try { window.turnstile.execute(_turnstileWidgetId); } catch (_err) { _resolveInFlight(""); return; } // Safety timeout — if CF never calls back within 6s, fall back. setTimeout(() => { // Only resolve if we're still the active in-flight call (the // callback path nulls _turnstileInFlightResolve on resolve). if (_turnstileInFlightResolve === wrappedResolve) { _resolveInFlight(""); } }, 6000); }); // Only publish to module global if not already settled synchronously. // If settledSync=true, the resolver path already nulled both globals; // we return the resolved promise to *this* caller but leave the // module slot clear so the NEXT call triggers a fresh execute(). if (!settledSync) { _turnstileInFlight = promise; } return promise; } // ── SSE consumer ─────────────────────────────────────────────────────────── // // EventSource is GET-only; /message is POST with a JSON body, so we use // fetch + ReadableStream.getReader() + TextDecoder. Each frame is // "event: \ndata: \n\n". We accumulate the buffer until a // terminating "\n\n" then dispatch ``onEvent({event, data})``. async function consumeSSE(url, body, onEvent, signal) { let resp; try { resp = await fetch(url, { method: "POST", headers: { "Content-Type": "application/json", Accept: "text/event-stream" }, credentials: "include", body: JSON.stringify(body), signal, }); } catch (e) { if (e && e.name === "AbortError") return { ok: false, status: 0, error: "aborted" }; return { ok: false, status: 0, error: "network" }; } if (!resp.ok) { let errText = `HTTP ${resp.status}`; try { const j = await resp.json(); if (j && j.error) errText = j.error; } catch (_e) { /* keep default */ } return { ok: false, status: resp.status, error: errText }; } const reader = resp.body && resp.body.getReader ? resp.body.getReader() : null; if (!reader) return { ok: false, status: 0, error: "no_stream" }; // Ensure the underlying stream is cancelled on signal-abort so the // ReadableStream lock is released (otherwise the connection stays // hot until GC / TCP RST). const onAbort = () => { try { reader.cancel(); } catch (_e) { /* ignore */ } }; if (signal) { if (signal.aborted) { onAbort(); return { ok: false, status: 0, error: "aborted" }; } signal.addEventListener("abort", onAbort, { once: true }); } const decoder = new TextDecoder("utf-8"); let buffer = ""; try { while (true) { let chunk; try { chunk = await reader.read(); } catch (e) { if (e && e.name === "AbortError") return { ok: false, status: 0, error: "aborted" }; throw e; } const { value, done } = chunk; if (done) break; buffer += decoder.decode(value, { stream: true }); let idx; while ((idx = buffer.indexOf("\n\n")) !== -1) { const frame = buffer.slice(0, idx); buffer = buffer.slice(idx + 2); let evName = "message"; let dataLines = []; for (const line of frame.split("\n")) { if (line.startsWith("event: ")) evName = line.slice(7).trim(); else if (line.startsWith("data: ")) dataLines.push(line.slice(6)); } if (dataLines.length === 0) continue; const rawData = dataLines.join("\n"); let parsed = null; try { parsed = JSON.parse(rawData); } catch (_e) { parsed = rawData; } try { onEvent({ event: evName, data: parsed }); } catch (_handlerErr) { /* handler bug — keep stream alive */ } } } return { ok: true, status: 200 }; } finally { if (signal) signal.removeEventListener("abort", onAbort); // Best-effort: release the reader lock (safe to call on a closed // reader; throws are swallowed). try { reader.releaseLock(); } catch (_e) { /* ignore */ } } } // ── Lead form (inline) ───────────────────────────────────────────────────── function LeadForm({ initialFields, onSubmitted, onCancel }) { const [name, setName] = useState(initialFields?.name || ""); const [contact, setContact] = useState(initialFields?.contact || ""); const [company, setCompany] = useState(initialFields?.company || ""); const [comment, setComment] = useState(initialFields?.comment || ""); const [status, setStatus] = useState({ kind: "idle", text: "" }); const handleSubmit = async (e) => { e.preventDefault(); const cleanName = name.trim(); const cleanContact = contact.trim(); if (!cleanName || !cleanContact) { setStatus({ kind: "err", text: "Укажите имя и контакт." }); return; } setStatus({ kind: "busy", text: "Отправляем…" }); try { const resp = await fetch("/api/public-lead", { method: "POST", headers: { "Content-Type": "application/json" }, credentials: "same-origin", body: JSON.stringify({ name: cleanName, contact: cleanContact, company: company.trim(), comment: comment.trim(), cta_source: "demo-chat", }), }); const j = await resp.json().catch(() => ({ ok: false, error: "Ошибка сервера." })); if (!resp.ok || !j.ok) { setStatus({ kind: "err", text: j.error || "Не удалось отправить." }); return; } setStatus({ kind: "ok", text: "Заявка отправлена! Свяжемся в ближайшее время." }); setTimeout(() => onSubmitted && onSubmitted(), 1200); } catch (_e) { setStatus({ kind: "err", text: "Нет связи. Попробуйте позже." }); } }; const inpStyle = { width: "100%", padding: "8px 10px", borderRadius: 8, border: "1px solid rgba(255,255,255,0.12)", background: "rgba(255,255,255,0.04)", color: "inherit", fontSize: 13, boxSizing: "border-box", fontFamily: "inherit", }; return (
Оставьте контакт — свяжемся в течение часа
setName(e.target.value)} placeholder="Как к вам обращаться?" maxLength={80} required style={inpStyle} aria-label="Имя" /> setContact(e.target.value)} placeholder="Телефон, Telegram или email" maxLength={120} required style={inpStyle} aria-label="Контакт" /> setCompany(e.target.value)} placeholder="Компания (опционально)" maxLength={120} style={inpStyle} aria-label="Компания" />