AI-чат на сайте может быть гораздо полезнее обычного окна с нейросетью. Вместо того чтобы просто отвечать на вопросы, он может выступать в роли виртуального помощника: узнать, что нужно потенциальному клиенту, задать несколько уточняющих вопросов, собрать контактные данные и передать владельцу сайта уже структурированный запрос.
В этой статье мы с нуля создадим именно такого помощника для сайта на MODX 2. Сделаем анимированный чат-виджет на Vanilla JavaScript, подключим бесплатную AI-модель через Groq, вынесем внешние API-запросы в Google Cloud Run, научим бота определять момент передачи лида и отправлять результат одновременно на email и в Telegram.
Причём разберём не только финальный код, но и реальные проблемы, которые возникли во время разработки: ограничения API по регионам, rate limits, хранение секретов, потерю контекста, повторные сообщения после ошибок и различия между почтовыми API MODX 2 и MODX 3.
Что получится в итоге: посетитель открывает чат, оставляет email, описывает задачу, AI уточняет необходимые детали, а когда информации становится достаточно — владелец сайта получает краткое AI-резюме и переписку на email, а также мгновенное уведомление в Telegram.
Исходный код проекта → GitHub
Что мы будем строить
Архитектура в итоге выглядит так:
Browser
│
▼
Animated Chat Widget
│
▼
MODX / PHP
│
├── conversation history
├── email validation
├── lead handling
├── email notification
│
▼
Cloud Run Gateway
│
├── Groq API
│ ↓
│ GPT-OSS 120B
│
└── Telegram Bot API
Почему появился Cloud Run, станет понятно немного позже.
1. Интерфейс чат-бота
Начнём с frontend.
В левом нижнем углу сайта находится небольшая круглая кнопка с плавной анимацией.
При клике она разворачивается в полноценный чат.
Для небольшого виджета React, Vue или другая frontend-библиотека совершенно не обязательны. Всё можно сделать на обычных:
HTML
CSS
Vanilla JavaScript
Это даёт несколько преимуществ:
- минимальный размер;
- отсутствие дополнительных зависимостей;
- полный контроль над анимацией;
- простое подключение практически к любому CMS.
Основная структура может выглядеть так:
<div class="fw-assistant">
<div class="fw-assistant-window">
<div class="fw-assistant-header">
AI Assistant
</div>
<div class="fw-assistant-body">
...
</div>
<div class="fw-assistant-footer">
...
</div>
</div>
<button class="fw-assistant-trigger">
...
</button>
</div>
Code language: HTML, XML (xml)Сам виджет размещается через:
.fw-assistant {
position: fixed;
left: 24px;
bottom: 24px;
z-index: 99999;
}
Code language: CSS (css)Для появления окна удобно использовать комбинацию:
opacity
visibility
transform
Например:
.fw-assistant-window {
opacity: 0;
visibility: hidden;
transform:
translateY(18px)
scale(0.92);
transition:
opacity .3s ease,
transform .4s cubic-bezier(.16, 1, .3, 1);
}
.fw-assistant.is-open .fw-assistant-window {
opacity: 1;
visibility: visible;
transform:
translateY(0)
scale(1);
}
Code language: CSS (css)Получается современное плавное раскрытие без тяжёлых JS-анимаций.
2. Сначала собираем email
Перед началом разговора посетителю предлагается оставить email.
Это полезно по двум причинам.
Во-первых, если пользователь закроет сайт после разговора, у нас уже останется контакт.
Во-вторых, самому AI не придётся постоянно спрашивать:
Как с вами связаться?
Email временно сохраняется в:
sessionStorage
Например:
sessionStorage.setItem(
'fwAssistantEmail',
email
);
Code language: JavaScript (javascript)В отличие от localStorage, данные исчезнут после окончания браузерной сессии.
3. Не забываем о frontend-валидации email
На backend email обязательно должен проверяться повторно, но неправильный адрес лучше вообще не пропускать в чат.
Простейшая frontend-проверка:
function isValidEmail(email) {
email = email.trim();
if (
email.length < 5 ||
email.length > 254
) {
return false;
}
return /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/i.test(email);
}
Code language: JavaScript (javascript)Если адрес некорректный:
if (!isValidEmail(email)) {
input.classList.add('is-invalid');
return;
}
Code language: JavaScript (javascript)А сервер всё равно должен повторить проверку:
if (
!filter_var(
$email,
FILTER_VALIDATE_EMAIL
)
) {
// reject request
}
Code language: PHP (php)Frontend нужен для UX.
Backend — для безопасности.
4. Почему нельзя обращаться к AI API напрямую из браузера
Плохой вариант:
Browser
↓
Groq API
Для этого пришлось бы передать API key в JavaScript.
Любой посетитель сможет открыть DevTools и увидеть его.
Правильная схема:
Browser
↓
your-site.com/api/chat.php
↓
AI API
API key должен знать только сервер.
5. PHP endpoint на MODX
На сайте можно создать, например:
/assets/components/assistant/api/chat.php
В нашем случае используется MODX 2, поэтому сначала подключаем MODX:
define(
'MODX_API_MODE',
true
);
$rootPath = dirname(__DIR__, 4);
require_once $rootPath . '/index.php';
Code language: PHP (php)После этого endpoint может читать системные настройки MODX:
$model = trim(
(string)$modx->getOption(
'portfolio_assistant.model',
null,
'openai/gpt-oss-120b'
)
);
Code language: PHP (php)Удобно завести отдельный namespace настроек:
portfolio_assistant.enabled
portfolio_assistant.model
portfolio_assistant.email_to
portfolio_assistant.max_messages
portfolio_assistant.gateway_url
portfolio_assistant.gateway_secret
portfolio_assistant.telegram_enabled
portfolio_assistant.telegram_gateway_url
Code language: CSS (css)Так настройки чат-бота можно менять через MODX Manager без редактирования PHP.
6. История разговора
AI должен помнить предыдущие сообщения.
Для MVP совсем не обязательно сразу создавать таблицы в базе.
Можно использовать PHP session:
$_SESSION[
'portfolio_assistant_conversation'
];
Code language: PHP (php)Например:
$conversation = [
'email' => $email,
'page_url' => $pageUrl,
'started_at' => time(),
'history' => [],
];
Code language: PHP (php)Каждое сообщение имеет очень простой формат:
[
'role' => 'user',
'content' => $message,
]
Code language: PHP (php)или:
[
'role' => 'assistant',
'content' => $reply,
]
Code language: PHP (php)7. Не нужно отправлять AI бесконечную историю
Если отправлять модели весь разговор целиком, стоимость и количество токенов будут постоянно расти.
Поэтому историю лучше ограничивать:
$history = array_slice(
$history,
-10
);
Code language: PHP (php)Для небольшого lead-assistant обычно достаточно последних нескольких пар сообщений.
8. System prompt важнее самой модели
Главная задача здесь — не создать универсальный ChatGPT.
Наоборот, AI должен быть максимально ограниченным.
Например:
You are the virtual AI assistant of Konstantin,
a freelance frontend and backend developer.
Your job is to help visitors understand
Konstantin's services and collect useful
information about their project.
Never pretend to be Konstantin.
Answer in the visitor's language.
Keep replies concise.
Never invent:
- prices
- deadlines
- availability
- portfolio projects
If information is insufficient,
ask one useful next question.
Do not ask all questions at once.
Code language: PHP (php)Также полезно явно перечислить специализацию:
PHP
JavaScript
HTML
CSS
WordPress
MODX
Node.js
API integrations
automation
website maintenance
performance optimization
Code language: CSS (css)Так бот становится не универсальной нейросетью, а именно виртуальным помощником.
9. AI должен знать, что email уже собран
Один интересный баг проявился во время тестирования.
Email вводился до начала чата, но в историю AI он не передавался.
В результате модель спрашивала:
Укажите вашу электронную почту.
хотя пользователь уже ввёл её несколько минут назад.
Решение — добавить server-controlled context:
$instructions .= "\n\nVISITOR CONTACT\n\n";
$instructions .=
"The visitor already provided this email address:\n";
$instructions .=
$email . "\n\n";
$instructions .=
"Never ask the visitor for their email again.\n";
Code language: PHP (php)Важно, что этот контекст создаётся сервером, а не браузером.
10. Почему понадобился Cloud Run
Первоначально PHP непосредственно обращался к AI API:
MODX
↓
AI provider
Но сервер сайта оказался в инфраструктуре, откуда некоторые внешние API работали нестабильно или вообще были недоступны по разным причинам.
Вместо переноса сайта мы добавили небольшой gateway:
MODX
↓
Google Cloud Run
↓
AI API
Это оказалось очень удобным архитектурным решением.
Сам сайт остаётся на прежнем хостинге.
Cloud Run выполняет только внешние API-запросы.
11. Cloud Run gateway
Gateway — это небольшой Node.js сервис.
Например:
import express from 'express';
const app = express();
app.use(
express.json({
limit: '64kb'
})
);
Code language: JavaScript (javascript)MODX обращается к:
POST /chat
Cloud Run после этого отправляет запрос AI-провайдеру.
12. Защищаем gateway отдельным секретом
Если оставить Cloud Run endpoint полностью открытым, любой человек сможет использовать его как бесплатный AI proxy.
Поэтому MODX передаёт:
X-Gateway-Secret: ...
Code language: HTTP (http)На Cloud Run:
const suppliedSecret =
req.get('x-gateway-secret') || '';
Code language: JavaScript (javascript)И проверяем:
if (
!secureEqual(
suppliedSecret,
GATEWAY_SECRET
)
) {
return res.status(401).json({
error: 'Invalid gateway credentials'
});
}
Code language: JavaScript (javascript)Получается:
Internet
↓
Cloud Run
без секрета → ❌
MODX
↓
X-Gateway-Secret
↓
Cloud Run → ✅
13. Храним секреты правильно
API-ключ AI-провайдера и Telegram Bot Token не должны лежать:
в JS
в Git
в HTML
в MODX template
в публичных PHP-файлах
В Cloud Run их удобно хранить через Google Secret Manager.
Например:
GROQ_API_KEY
GATEWAY_SECRET
TELEGRAM_BOT_TOKEN
Cloud Run получает их как environment secrets.
14. Бесплатный AI через Groq
Для MVP можно использовать разные модели, но поскольку у нас задача сделать бесплатное решение, то выбор пал на Groq:
Groq
+
openai/gpt-oss-120b
MODX отправляет:
$payload = [
'model' => $model,
'store' => false,
'reasoning' => [
'effort' => 'low',
],
'instructions' =>
$instructions,
'input' =>
$history,
'max_output_tokens' =>
400,
];
Code language: PHP (php)Cloud Run вызывает AI и возвращает ответ PHP.
15. Не забываем о rate limits
Даже бесплатный API имеет ограничения.
Особенно важно учитывать не только количество запросов, но и количество токенов.
Большой system prompt плюс история разговора могут быстро увеличить расход.
Поэтому полезно:
- ограничивать историю;
- делать ответы короткими;
- не использовать unnecessarily large output limits;
- не дублировать информацию в prompt;
- использовать prompt caching, если API его поддерживает.
16. Когда считать лид готовым?
Самая интересная часть — AI должен понять, когда уже нет смысла продолжать допрос пользователя.
Например:
User:
Нужен блог на WordPress.
Assistant:
Есть дизайн?
User:
Да, нужен под ключ,
ТЗ уже готово.
Этой информации уже может быть достаточно для передачи человеку.
Поэтому модель получает дополнительную задачу:
For every response determine whether
the visitor's request is sufficiently clear
to hand off to Konstantin.
И возвращает:
{
"reply": "...",
"ready_to_handoff": true,
"handoff_message": "...",
"lead": {
"name": "Иван",
"website": null,
"request": "WordPress blog",
"summary": "..."
}
}
Code language: JSON / JSON with Comments (json)17. Structured Output вместо парсинга текста
Очень плохая идея искать в обычном тексте что-то вроде:
HANDOFF_READY
или:
[send_email=true]
Code language: JSON / JSON with Comments (json)Гораздо надёжнее заставить модель отвечать по JSON schema.
Например:
'text' => [
'format' => [
'type' => 'json_schema',
'name' =>
'portfolio_assistant_turn',
'schema' => [
...
],
],
],
Code language: PHP (php)Тогда PHP получает структурированный результат.
18. AI не должен сам утверждать, что сообщение отправлено
Это важная архитектурная деталь.
Модель может сказать:
"ready_to_handoff": true
Code language: JavaScript (javascript)Но это ещё не означает, что email действительно отправился.
Поэтому логика должна быть:
AI:
ready_to_handoff = true
↓
PHP отправляет email
↓
успех?
↓
YES
↓
показываем пользователю:
"Запрос передан"
Code language: JavaScript (javascript)Если SMTP упал:
AI:
ready_to_handoff = true
↓
email failed
↓
НЕ сообщаем,
что сообщение отправлено
Code language: JavaScript (javascript)Так AI не сможет случайно соврать пользователю.
19. Не записываем сообщение в session слишком рано
Во время разработки обнаружился ещё один интересный баг.
Первоначально сообщение пользователя сразу записывалось:
$conversation['history'][] = [
'role' => 'user',
'content' => $message,
];
Code language: PHP (php)А потом выполнялся AI-запрос.
Если AI или email возвращал ошибку, пользователь нажимал «повторить».
В истории оказывалось:
User:
одно и то же сообщение
User:
одно и то же сообщение
Правильнее сначала создать временную историю:
$history =
$conversation['history'];
$history[] = [
'role' => 'user',
'content' => $message,
];
Code language: PHP (php)И только после успешного завершения запроса:
$conversation['history'] =
$history;
Code language: PHP (php)Это фактически небольшой transaction-like подход.
20. Email через MODX 2
Важно не перепутать MODX 2 и MODX 3.
В MODX 2 mail service подключается так:
$mail = $modx->getService(
'mail',
'mail.modPHPMailer'
);
Code language: PHP (php)Далее:
$mail->set(
modMail::MAIL_BODY,
$html
);
$mail->set(
modMail::MAIL_FROM,
$sender
);
$mail->set(
modMail::MAIL_SUBJECT,
'[Portfolio Lead] ' . $subject
);
$mail->address(
'to',
$emailTo
);
$mail->address(
'reply-to',
$visitorEmail
);
$mail->setHTML(true);
$sent =
$mail->send();
$mail->reset();
Code language: PHP (php)В письме удобно отправлять:
Email клиента
Имя
Сайт
Страницу, с которой открылся чат
Тип задачи
AI Summary
Полную переписку
21. Telegram как второй канал
Email хорош для подробностей.
Telegram хорош для скорости.
Поэтому при handoff можно одновременно отправить:
Email
+
Telegram
Telegram-сообщение достаточно сделать компактным:
🚀 New Portfolio Lead
👤 Name: Иван
📧 Email: ivan@example.com
🌐 Website: example.com
📋 Request
Создание сайта на WordPress.
🤖 AI Summary
Клиенту нужен корпоративный сайт...
Code language: CSS (css)Полную переписку лучше оставить в email.
22. Почему Telegram тоже отправляется через Cloud Run
Во время тестирования обнаружилось, что сервер сайта не может стабильно обратиться к Telegram по тем же причинам что и раньше 
api.telegram.org
Code language: CSS (css)Запрос просто уходил в timeout.
Но Cloud Run уже был доступен.
Поэтому схема стала:
MODX
↓
Cloud Run /telegram
↓
Telegram Bot API
Token Telegram при этом хранится только в Secret Manager.
Получается, что MODX вообще не знает bot token.
23. Cloud Run endpoint для Telegram
На gateway добавляем:
app.post(
'/telegram',
async (req, res) => {
...
}
);
Code language: JavaScript (javascript)Cloud Run получает только данные лида:
{
"name": "Иван",
"email": "ivan@example.com",
"website": "example.com",
"request": "Создать сайт",
"summary": "...",
"page_url": "https://example.com/"
}
Code language: JSON / JSON with Comments (json)После чего сам обращается к:
https://api.telegram.org/bot.../sendMessage
Code language: JavaScript (javascript)24. Email и Telegram должны быть независимыми
Неправильная логика:
Telegram failed
↓
весь handoff failed
Telegram — всего лишь дополнительное уведомление.
Поэтому лучше:
$handoffSent =
$emailSent ||
$telegramSent;
Code language: PHP (php)То есть если хотя бы один канал успешно доставил запрос, handoff состоялся.
Дополнительно можно вернуть:
{
"handoff_sent": true,
"handoff_channels": {
"email": true,
"telegram": true
}
}
Code language: JSON / JSON with Comments (json)Очень удобно во время отладки.
25. Что получает frontend
Обычный ответ:
{
"ok": true,
"message": "Можете уточнить..."
}
Code language: JSON / JSON with Comments (json)После успешной передачи:
{
"ok": true,
"message": "Спасибо! Я передал ваш запрос Константину.",
"handoff_sent": true
}
Code language: JSON / JSON with Comments (json)JavaScript после этого блокирует форму:
if (result.handoffSent) {
chatInput.disabled = true;
chatInput.placeholder =
'Запрос передан Константину ✓';
sendButton.disabled = true;
}
Code language: JavaScript (javascript)Разговор естественно заканчивается.
26. Итоговая архитектура
В итоге получился довольно интересный стек:
┌─────────────────────────┐
│ Website Visitor │
└────────────┬────────────┘
│
▼
┌─────────────────────────┐
│ Animated Vanilla JS Chat│
└────────────┬────────────┘
│
▼
┌─────────────────────────┐
│ MODX 2 / PHP │
│ │
│ • session │
│ • email validation │
│ • history │
│ • system prompt │
│ • lead logic │
│ • email │
└────────────┬────────────┘
│
▼
┌─────────────────────────┐
│ Google Cloud Run │
│ │
│ /chat │
│ /telegram │
└────────┬─────────┬──────┘
│ │
▼ ▼
┌─────────────┐ ┌───────────────┐
│ Groq │ │ Telegram API │
│ GPT-OSS │ └───────────────┘
│ 120B │
└─────────────┘
Что получилось в итоге
Наш бот теперь не просто отвечает на вопросы.
Он выполняет конкретную бизнес-задачу:
Visitor
↓
Conversation
↓
Requirements clarification
↓
Lead qualification
↓
AI summary
↓
Email + Telegram
↓
Human follow-up
То есть AI здесь не заменяет человека.
Он выполняет роль первого помощника и передаёт разработчику уже структурированный запрос.
Что можно добавить позже
Текущий MVP уже полностью рабочий, но его можно развивать дальше.
Например:
- сохранять лиды в отдельную таблицу MODX;
- сделать историю обращений в Manager;
- добавить кнопку «Начать новый разговор»;
- подключить базу знаний по статьям сайта;
- автоматически передавать AI информацию о portfolio;
- добавить analytics events;
- ограничивать злоупотребления по IP;
- подключить Cloudflare Turnstile при подозрительной активности;
- добавить fallback AI-провайдера;
- дать пользователю возможность приложить файл с ТЗ;
- отправлять вложения вместе с лидом.
Но для первой версии этого уже более чем достаточно.
Главный вывод
При создании AI-бота для сайта самое интересное находится не в одной строке:
call AI API
А вокруг неё.
Нужно продумать:
UX
security
API keys
conversation state
rate limits
failures
email
Telegram
handoff
validation
network restrictions
Именно эти детали превращают небольшой AI demo в настоящий рабочий инструмент.
В нашем случае получился виртуальный помощник, который принимает входящий запрос, задаёт разумные уточняющие вопросы, не придумывает цены и сроки, понимает момент передачи задачи человеку и уведомляет владельца сайта сразу по двум каналам.
А frontend при этом остаётся обычным лёгким Vanilla JavaScript widget без дополнительных библиотек.
Исходный код проекта → GitHub
