<!-- html -->
<script src="https://my-domen.ru/public/embed.js"></script>Рис. 1. Вид виджета на сайте в закрытом виде.
/* php */
class YandexClient {
private $apiKey;
private $folderId;
private $modelName;
public function __construct() {
// Ключ и ID каталога читаются из переменных окружения,
// а не хранятся прямо в коде — это отдельный секретный файл,
// недоступный из браузера ни при каком URL.
$this->apiKey = getenv('YANDEX_API_KEY'); // Токен Яндекс: AQVN••••••••••••••••3f8k
$this->folderId = getenv('YANDEX_FOLDER_ID'); // b1g••••••••••••••01u
$this->modelName = getenv('YANDEX_MODEL_NAME');
}
public function sendMessage($messages) {
$url = 'https://llm.api.cloud.yandex.net/foundationModels/v1/completion';
$modelUri = "gpt://{$this->folderId}/{$this->modelName}/latest";
$data = [
'modelUri' => $modelUri,
'completionOptions' => [
'stream' => false,
'temperature' => 0.6, // баланс "живости" и предсказуемости ответа
'maxTokens' => '2000' // жёсткий потолок длины одного ответа
],
'messages' => $messages
];
$ch = curl_init($url);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($data, JSON_UNESCAPED_UNICODE));
curl_setopt($ch, CURLOPT_HTTPHEADER, [
'Content-Type: application/json',
"Authorization: Api-Key {$this->apiKey}"
]);
curl_setopt($ch, CURLOPT_TIMEOUT, 25);
$response = curl_exec($ch);
$result = json_decode($response, true);
return $result['result']['alternatives'][0]['message']['text'];
}
}/* php */
$systemPrompt = file_get_contents('private/prompt-rules.md') // как отвечать: тон, формат
. file_get_contents('private/knowledge.md') // что известно о бизнесе
. renderContactsAsTokenList(getContacts()); // какие сейчас есть токены контактов/* php */
function expandContactTokens(string $html, array $contacts): string {
// {{contacts_block}} → полный список каналов связи,
// {{phone}}, {{telegram}}, {{whatsapp}} → отдельные ссылки
return str_replace(
array_keys($contacts),
array_values($contacts),
$html
);
}/* javascript */
// Общая сверка с сервером — вызывается и при открытии окна, и по
// таймеру, пока окно остаётся открытым.
async function syncWithServer() {
try {
const res = await fetch(`${CONFIG_URL}?sessionId=${encodeURIComponent(sessionId)}`);
const data = await res.json();
if (data.totalCount > knownMessageCount) {
const missingCount = data.totalCount - knownMessageCount;
const newTail = data.history.slice(-missingCount);
// Автоскролл вниз — только если пользователь и так был
// у нижнего края чата. Если он листает историю выше —
// его текущий вид не трогаем.
const wasAtBottom =
messagesEl.scrollHeight - messagesEl.scrollTop - messagesEl.clientHeight < 40;
appendHistoryMessages(newTail);
if (wasAtBottom) messagesEl.scrollTop = messagesEl.scrollHeight;
knownMessageCount = data.totalCount;
}
} catch (e) {
// Тихо игнорируем — не критично, просто не дозагрузили в этот раз.
}
}
// Таймер живёт, только пока окно чата открыто
let pollIntervalId = setInterval(syncWithServer, 15000);
// И гарантированно гасится при закрытии окна —
// незачем слать запросы, пока чат никто не смотрит
function stopPolling() {
clearInterval(pollIntervalId);
pollIntervalId = null;
}Рис. 2. Автоматическая дозагрузка диалога без единого лишнего клика.
/* javascript */
const initial = { sessionCount: <?= $initialMarker['sessionCount'] ?>,
latestAt: <?= json_encode($initialMarker['latestAt']) ?> };
async function checkForUpdates() {
try {
const res = await fetch('check-updates.php');
const data = await res.json();
// Сравниваем со снимком состояния на момент загрузки страницы
if (data.sessionCount !== initial.sessionCount || data.latestAt !== initial.latestAt) {
document.getElementById('u11-update-banner').style.display = 'block';
}
} catch (e) {
// Тихо игнорируем — следующая попытка через 25 секунд
}
}
setInterval(checkForUpdates, 25000);/* php */
// check-updates.php
function getActivityMarker() {
$stmt = getDb()->query(
'SELECT COUNT(DISTINCT session_id) AS session_count,
MAX(created_at) AS latest_at
FROM messages'
);
return $stmt->fetch(PDO::FETCH_ASSOC);
}
echo json_encode(getActivityMarker());Рис. 4. Вид на стороне администратора: список диалогов.
/* php */
// check-updates.php — с параметром ?session=...
$sessionId = $_GET['session'] ?? null;
if ($sessionId) {
function countSessionMessages($sessionId) {
$stmt = getDb()->prepare('SELECT COUNT(*) FROM messages WHERE session_id = ?');
$stmt->execute([$sessionId]);
return (int) $stmt->fetchColumn();
}
echo json_encode(['messageCount' => countSessionMessages($sessionId)]);
}/* javascript */
const sessionId = <?= json_encode($viewingSessionId) ?>;
const initialMessageCount = <?= (int) $initialMessageCount ?>;
async function checkForSessionUpdates() {
const res = await fetch('check-updates.php?session=' + encodeURIComponent(sessionId));
const data = await res.json();
if (data.messageCount > initialMessageCount) {
document.getElementById('u11-session-update-banner').style.display = 'block';
}
}
setInterval(checkForSessionUpdates, 15000);Рис. 5. Вид на стороне администратора: просмотр конкретного диалога.
/* javascript */
const BUBBLE_COOLDOWN_MS = 24 * 60 * 60 * 1000; // не чаще раза в сутки
const BUBBLE_SHOW_DELAY_MS = 3000; // задержка перед показом
const BUBBLE_VISIBLE_WINDOW_MS = 10000; // сколько "живёт" на экране
let bubbleShownAt = null;
let bubblePendingShow = false;
let bubblePermanentlyClosed = false;
function isBubbleEligible() {
const lastShown = Number(localStorage.getItem('u11_bubble_last_shown') || 0);
return !bubblePermanentlyClosed && (Date.now() - lastShown) > BUBBLE_COOLDOWN_MS;
}
function showBubbleNow() {
bubble.classList.add('visible');
bubbleShownAt = Date.now();
localStorage.setItem('u11_bubble_last_shown', String(bubbleShownAt));
// Плашка обязана "прожить" 10 секунд с момента ПЕРВОГО показа,
// даже если пользователь несколько раз проскроллит вверх-вниз
setTimeout(() => {
bubble.classList.remove('visible'); // плавное угасание по CSS-transition
bubblePermanentlyClosed = true;
}, BUBBLE_VISIBLE_WINDOW_MS);
}
function closeBubbleInstantly() {
// Мгновенно, БЕЗ fade-анимации — иначе при клике на 9-й секунде
// плашка "доигрывала" бы исчезновение уже поверх открытого чата
bubble.style.transition = 'none';
bubble.classList.remove('visible');
requestAnimationFrame(() => { bubble.style.transition = ''; });
bubblePermanentlyClosed = true;
}
function handleScroll() {
const buttonVisible = window.scrollY > SCROLL_THRESHOLD;
btn.classList.toggle('visible', buttonVisible);
if (buttonVisible && isBubbleEligible() && !bubbleShownAt) {
setTimeout(() => {
if (buttonVisible) showBubbleNow();
else bubblePendingShow = true; // покажем, как только кнопка снова станет видна
}, BUBBLE_SHOW_DELAY_MS);
}
}
btn.addEventListener('click', closeBubbleInstantly);
bubble.addEventListener('click', () => { closeBubbleInstantly(); openChat(); });Рис. 6. Плашка-приглашение виджета — небольшая деталь, которая работает на конверсию.
Рис. 7. Вид админ-панели с пользователями.
/* php */
// Так БЫЛО — один диалог мог попасть в результаты
// столько раз, сколько раз в нём встретилось слово:
$stmt = $db->prepare(
'SELECT session_id, created_at FROM messages
WHERE content_lower LIKE ?'
);
// Так СТАЛО — сначала находим уникальные session_id,
// затем уже собираем сводку по каждому диалогу одним рядом:
$stmt = $db->prepare(
"SELECT session_id, MIN(created_at) AS started_at,
MAX(created_at) AS last_message_at,
COUNT(*) AS messages_count
FROM messages
WHERE session_id IN (
SELECT DISTINCT session_id FROM messages WHERE content_lower LIKE ?
)
GROUP BY session_id
ORDER BY last_message_at DESC"
);/* php */
$fromAddress = 'no-reply@' . $domainHost; // например, no-reply@u11.ru
$headers = "From: {$fromAddress}\r\nContent-Type: text/plain; charset=UTF-8\r\n";
// Envelope sender задаётся ЯВНО через параметр -f — без этого письма
// не доходили даже при корректно настроенных SPF и DKIM в DNS: сервер
// подставлял свой технический адрес в служебный MAIL FROM на уровне
// SMTP-диалога, и почтовые провайдеры проверяли SPF именно по нему,
// а не по видимому заголовку «От кого» в самом письме.
$envelopeSender = '-f' . $fromAddress;
mail($toEmail, $subject, $body, $headers, $envelopeSender);Рис. 8. Восстановление пароля через письмо с доменной почты.