Сохранить порядок дополнительных Compose-файлов при обновлении и подключать их помощнику на чтение. Добавить проверки override, постоянных каталогов dev-данных и документацию проекта. Явно задать русский язык в тестах русских подписей.
На экранах до 480px лоадер раскрывает панель на весь экран без
скруглений и прячет круглую кнопку, пока панель открыта: иначе она
висит поверх поля ввода. Кнопку «Развернуть окно» панель не показывает —
разворачивать некуда. Ширину экрана iframe не видит, поэтому режим
сообщает лоадер (chatballs-chat-layout), а панель после монтирования
сама его запрашивает.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Виджет переложен по образцу, который дал владелец.
Лента: имя и аватар агента стоят над пузырём, у пузыря не скруглён верхний
угол со стороны автора — «хвостик». Аватар агента — знак робота на круге
цвета акцента; он же стоит в шапке вместо буквы, пока у виджета нет способа
получить логотип компании, и он же на кнопке чата вместо прежней подписи.
Пока ответ считается, в ленте висит «печатает» с бегущими точками.
Поле ввода: без рамки, скрепка слева, справа запись голосового, которая
сменяется отправкой, как только клиент начал печатать. При росте поля кнопки
держатся верхней строки. Подсказки убраны. Подвал — одного тона с лентой,
внизу «Работает на Chatballs» со ссылкой на сайт.
В шапке появилась кнопка разворота: окно становится почти вдвое шире и
немного выше. Размер держит лоадер на сайте-хосте — панель живёт в iframe и
сама себя не растянет.
Появление и исчезновение — эффект джина, как в доке macOS: окно вытягивается,
изгибается горловиной и втягивается в кнопку. Аффинными преобразованиями
такое не делается, поэтому силуэт считается контуром SVG покадрово, тем же
контуром режется контейнер панели, а само окно внутри получает матрицу,
построенную по этому силуэту, — вместе с окном деформируется и переписка.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
«Удалить диалог» удаляет: переписку, вложения, звонки и уведомления о нём. Право только у владельца и администратора, писать в удалённый диалог некуда, лента виджета у клиента обнуляется по признаку reset в поллинге.
База знаний: категории слева сворачиваются и разворачиваются (свёрнутое помнит браузер), в подвале появился выбор «На странице» 20/50/100 — общий для всех списков компонент.
Окно «Знания» карточки агента разложено по категориям и порталам со счётчиками и «Выбрать все»; набор материалов грузится целиком, а не первой страницей.
Пилюля онбординга перетаскивается и помнит место. Голосовое в веб-чате нарисовано плеером оператора без расшифровки, рисунок волны вынесен в общий пакет. Убрана горизонтальная полоса прокрутки в меню меток.
Проверено: typecheck internal-ui и web-chat, backend-тесты диалогов, виджета и i18n.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Язык ответов агента. Поле AIAgent.answer_language: «как у клиента»
(по умолчанию), «язык организации» или конкретный язык. Системный промпт при
этом не переводится — его читает модель, а не человек; язык задаёт отдельная
директива, потому что русский промпт сам по себе тянет ответ в русский язык.
База знаний остаётся одноязычной: поиск ведёт семантическая ветка, а
эмбеддинги кроссязычные.
Язык уходящего наружу текста. Приглашение на звонок, просьба поделиться
контактом, подписи кнопок мессенджеров, тема письма, приветствие виджета и
ответы бота уведомлений берут язык организации (i18n.customer_language), а не
язык запроса: клиент не должен получать письмо на языке того сотрудника, кто
нажал кнопку, и половина такого текста рождается в воркере, где запроса нет.
Английское демо. Манифесты разложены по demo_seed/data/<язык>/, набор
выбирается языком организации. Английский набор повторяет русский по составу —
тот же каст, те же тринадцать диалогов, — переводился только текст. Свои
документы знаний и каталог тканей; аватары и голосовые общие. Демо-провайдер
стал двуязычным: наборы стоп-слов и ключевых слов на оба языка, язык
определяется по письму клиента.
Выбор языка в мастере первого запуска. Переключатель меняет экран без
перезагрузки: форму уже заполняют, а пароль в хранилище браузера класть
нельзя. Выбранный язык становится языком установки, и на нём же идёт вся
установка — должность владельца, демо-данные, ошибки формы.
Попутно исправлено:
- Демо-стенд нельзя было редактировать. configure_agent_provider принимал
только OpenRouter и Custom, а демо ставит агентов на встроенный DEMO.
providerIntegrationId уходит с каждым PATCH карточки, поэтому падало любое
сохранение агента — даже правка инструкций.
- Список типов уведомлений отдавал ярлык TextChoices — захардкоженную русскую
строку. Ярлык переехал ключом в реестр TYPE_META.
- Результат «Проверить» у подключений (20 строк) и ошибки политики исходящих
адресов уходили владельцу по-русски.
- Правило data/ в .gitignore ловило demo_seed/data: русские манифесты лежали в
индексе с прежних времён, а новые файлы туда молча не попадали бы.
Каталог бэкенда — 519 ключей, наборы ru и en совпадают. Полный прогон
бэкенда: 759 passed, 1 skipped.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Интерфейс был русским в коде: строки лежали прямо в JSX и в ответах API,
даты форматировались прибитым «ru-RU», склонение по числу писалось руками
в каждом файле. Теперь текста в коде нет — он живёт в словарях, а язык
выбирается человеком.
Цепочка одна на обе стороны: профиль сотрудника → организация → установка
→ браузер. Пустое значение на каждом уровне значит «как выше», а не
«русский»: тот, кто язык не трогал, поедет за организацией, когда владелец
её переключит, а выбравший явно — останется на своём.
Владелец задаёт язык организации («Настройки» → «Организация», рядом с
часовым поясом) и язык установки («Платформа») — на нём открываются вход,
сброс пароля и мастер первого запуска. Сотрудник переопределяет его в
профиле.
Русский каталог задаёт набор ключей, английский обязан его повторить:
во фронтенде это ловит tsc — пропущенный перевод становится ошибкой
сборки, — на бэкенде тест каталога, который заодно сверяет имена
параметров в фразах. Формы множественного числа берутся из CLDR через
Intl.PluralRules, даты и размеры — через Intl, а не через свои списки
месяцев.
Отдельно пришлось разобраться с тем, что уже записано в базу. Системные
события диалога писались готовой русской фразой, и перевести историю
задним числом нельзя — теперь пишется код события, а фразу собирает
сервер на языке читателя. То же с уведомлениями: они адресованы
операторам, а не одному человеку, и в смешанной команде готовая фраза
неверна для половины. Тон системной строки в треде и признак анонимного
посетителя больше не угадываются регуляркой по русским словам: под
английским бэкендом это просто перестало бы работать.
Письма уходят на языке получателя, а не отправителя: приглашение
отправляет владелец, а читает другой человек.
gettext не взят намеренно: .po/.mo потребовали бы msgfmt в сборке образа
ради того же результата, что дают обычные словари, одинаковые на обеих
сторонах. Язык запроса при этом активируется штатным механизмом Django,
поэтому сообщения DRF переводятся тоже.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Вёрстка:
- мобильная сцена брала высоту как `height:100%` от родителя, у которого задан
только `min-height` — процент схлопывался в высоту по контенту, и окно выходило
412x427 вместо 412x915. Высота берётся от вьюпорта (vh/dvh), как у видеозвонка;
- на низком десктопном окне карточка 9:16 не влезала и кнопки приёма уезжали за
фолд — ширина зажата по высоте вьюпорта, пропорция остаётся ровно 9:16;
- операторской модалке нужен `height:auto`, иначе мобильная ветка растягивала
сцену на весь экран внутри antd Modal;
- пустая «пилюля» канала больше не рисуется без метки.
Жизненный цикл:
- токен из хеша сохраняется в sessionStorage: хеш срезается replaceState, а в
пути лежит callId, поэтому после F5 звонок было не восстановить;
- обрабатывается CONNECTED от собеседника — UI выходит из «Переподключения»,
когда обрыв был на его стороне;
- у режима reconnecting появился нижний бар: экран был без единой кнопки;
- клиенту не показывается «Позвонить снова», которого он не может сделать; на
DECLINED/MISSED/EXPIRED это была единственная кнопка, и уйти было некуда;
- «Повторить» на упавшем соединении рестартует RTC, а не перепроверяет микрофон.
Чистка:
- TERMINAL_CALL_STATUSES стал единственным источником вместо трёх копий набора;
- волна в тишине спокойная: амплитуда растёт от idle к speak по громкости;
- сигналинг и track.enabled вынесены из updater'а setState.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Документация переработана: действующее отделено от истории, отменённые
контуры (продажи, биллинг, managed AI, отделы, сущность Product) убраны из
действующих документов в архив.
Здесь — только кодовая часть: ссылки на документы в комментариях. Ссылки на
действующие документы переименованы ADR/SPEC/ARCH/BUS-HUB-NNNN →
*-CHATBALLS-NNNN (274 ссылки в 173 файлах). Ссылки на документы, ушедшие в
архив, намеренно сохранили прежний идентификатор: он совпадает с именем
архивного файла.
Логика не менялась — правки только в комментариях, докстрингах и одном
описании теста.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Публичные endpoint'ы виджета принимают кого угодно без входа и без CSRF, и
до сих пор у них не было ни одного ограничителя. Три дыры разом.
Origin страницы-хозяина брался из поля `hostOrigin` тела запроса — раньше
заголовков. Поле заявляет клиент, поэтому ограничение по доменам виджета
(`allowed_origins`) обходилось строчкой в curl. Теперь решают заголовки,
которые ставит браузер, а `hostOrigin` читается только там, где они указывают
на нас самих: виджет живёт в iframe на нашем же origin, и узнать хозяина
оттуда больше нечем. Что это заявление, а не доказательство, записано в
докстринге `host_origin` — чтобы поверх не построили настоящий контроль.
Лимитов не было вовсе. Каждое сообщение — ход AI по ключу организации
(ingest зовёт провайдера прямо в запросе), каждый файл — место в хранилище:
один скрипт тратил чужие деньги и диск, а поток порождённых событий вдобавок
вытеснял из воркера поллинг мессенджеров. Контуров два. По адресу клиента —
для того, у кого сессии ещё нет; потолки нарочно высокие, потому что за одним
адресом мобильного оператора сидят тысячи живых посетителей. По токену сессии
— строго: за NAT адрес общий, и счёт по нему либо бьёт по соседям, либо
бесполезен. Токен для лимита достаётся из заголовка, а не из тела: иначе
двадцатимегабайтная загрузка доезжала бы до сервера прежде, чем её отобьют.
Сессия не истекала никогда. Токен лежит в localStorage: на общем компьютере
он открывал бы чужую переписку сколько угодно долго. Теперь срок считается от
последней активности (30 суток), отметка обновляется не чаще раза в час —
иначе опрос раз в 2.5 с писал бы в строку сессии четыре раза в минуту.
Виджет на 401 забывает токен и предлагает начать заново, а не висит молча.
Пять тестов. Для главного отдельно проверено, что он ловит прежнее поведение:
со старым `_origin` подделанный `hostOrigin` давал 201 вместо 404.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`Product` оставался в схеме «скрытой технической осью» (ADR-HUB-0041 §7), но
из интерфейса не исчез: в настройках портала жил раздел «Продукты и поддержка»,
в списке порталов — колонка «Продукты». Решение владельца — убрать сущность
целиком вместе со всем контуром, который на ней держался.
Удалено:
- приложения `products` и `support` (контракты идентификации, снимки личности,
Product Support Token, публичные endpoints сессии);
- `Channel.product` и `requires_authenticated_product_identity` (инварианты
P3-P5; остались P1-P2), `LlmInvocation.product`, `SupportPortalProduct`;
- `WebChatWidget.mode` целиком: различать было нечего, у веб-виджета одна
анонимная точка входа. Ключ `mode` ушёл и из ответов API виджета;
- `mode=support` и `ChatballsChat.init` в лоадере, `SupportApp` в web-chat;
- capability `products.*`, аудит-действия `products.*`, продукты в карточке
контакта и в статистике (`byProduct`);
- раздел настроек портала и колонка списка — в коде и в дизайн-базлайне v2.
У диалога один источник identity — контакт: XOR заменён на
`conversation_requires_contact`. `PUT /portals/{id}/products/` удалён,
`GET /portals/{id}/support-channels/` → `GET /portals/{id}/widgets/`.
Миграции — по образцу сноса продаж: исторические миграции уцелевших приложений
вычищены от ссылок (чистая установка объектов не создаёт), существующие БД
чинит `tenancy/0029_drop_product_support`. Данные не экспортируются, но диалоги
со снимком личности не удаляются — каждому создаётся контакт с именем из снимка.
Проверено: backend 583 OK, фронт tsc + vitest 77 OK, сборки internal-ui и
web-chat OK, миграция прогнана на локальной БД, публичный портал открывается.
В коммит также вошли накопленные незакоммиченные правки рабочего дерева
(библиотека знаний, настройки, dev-compose и nginx, макеты базлайна).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Первичное требование: поднял докер — прошёл мастер — дальше всё в интерфейсе.
Ни одной переменной окружения задавать не нужно и негде.
Запуск и секреты:
- .env.example удалён, мёртвые переменные вычищены; секреты инстанса
генерирует одноразовый сервис secrets в именованный том, пароли БД —
через POSTGRES_*_PASSWORD_FILE;
- Caddy: catch-all :80 и on_demand TLS вместо хостов в конфиге — свежая
коробка отвечает по IP и по любому домену, до мастера дойти можно;
- адрес установки, SMTP и TURN переехали в настройки (InstanceSettings,
миграции 0027–0030), внешние ссылки строятся от него;
- deploy/cli больше не читает .env; сборка образов в ghcr через GitHub
Actions, release.env с digest-пинами;
- куки Secure/__Host- выставляются по факту TLS запроса, а не настройкой.
Порталы (дизайн-базлайн v2, кадры PT1–PT8):
- список, карточка портала, библиотека материалов, редактор статьи и
настройки — ширины и ритм как в остальных разделах;
- файлы статьи: изображение вставляется в текст своим механизмом, файл
прикрепляется вложением и выводится на портале списком с иконкой формата;
- ссылки на файлы приводятся к относительным: абсолютный хост резал CSP
портала и картинка не появлялась;
- «Опубликовать» сверяется с сервером и публикует то, что на экране, а не
ранее выбранную редакцию;
- колонка «Оценки» в списке статей и блок оценок в редакторе.
Первый запуск: полоса «демо-данные устанавливаются» — установка идёт в
worker, и без неё человек видел пустые разделы без объяснения.
Починен фон: вторичное хранилище стало best-effort — при живых остаточных
S3-ключах повторная загрузка файла падала в 500.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
AI: входящее голосовое (TG/MAX/виджет/портал) расшифровывается BYOK-провайдером
при входе, AI отвечает текстом по стенограмме; стенограммы участвуют в истории
контекста. Без расшифровки (провайдер не умеет/недоступен, FAILED) — диалог
оператору с уведомлением, как прежде. Демо-провайдер честно отказывает
(ProviderError) вместо текста-заглушки. Кнопка «Расшифровать» использует тот
же helper (transcribe_voice_message).
Портал поддержки: POST /support/sessions/messages/ принимает multipart audio и
file (+подпись), отдача /messages/<id>/audio|attachment/?credential=; в poll —
kind/hasAudio/durationSeconds/attachment; старт сессии возвращает features.
Виджет support-режима: запись голосового, скрепка, плеер и карточки.
Настройки → «Голосовые и звонки»: матрица точек входа × Голосовые /
Аудиозвонки / Видеозвонки (Integration.voice_messages_enabled,
audio_calls_enabled, video_calls_enabled; integrations/0007;
GET/PATCH company/administration/communication/). Флаги отдаются в
conversation.connection и в config/сессии виджетов: композер прячет микрофон,
карточка контакта — кнопки звонка, бэкенд отклоняет запись/звонок (400/403).
Почта звонки не поддерживает («недоступно»).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Сообщение kind=file с вложением (имя, тип, размер) в тенантном хранилище.
Приём: Telegram (document/photo через getFile), MAX (attachments file/image),
почта (вложения письма вместо заглушки «не поддерживаются»), web-виджет
(multipart file + подпись). Файл без текста уходит оператору, как голосовое.
Отправка оператором: POST /conversations/<id>/attachments/ (файл + подпись,
перехват диалога как у текста) — Telegram sendDocument/sendPhoto, MAX /uploads,
почта MIME-вложение, Web поллингом. Отдача: /messages/<id>/attachment/ (?inline
для фото/PDF) и /webchat/messages/<id>/attachment/ по токену сессии.
Ограничения: 20 МБ, исполняемые типы отклоняются.
UI: кнопка «Прикрепить» (32px, как в макете), чип файла над полем, вставка из
буфера; в ленте — превью фото или карточка файла со ссылкой «Скачать» (.link);
превью в списке «Фото / Файл · имя». Виджет: скрепка в композере, чип, карточки.
email.py: выправлены двойные CR в концах строк.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
- нет зашитых доменов: help-домен по умолчанию localhost (задаёт установщик через
CHATBALLS_HELP_BASE_DOMAIN), фронт определяет портал помощи пробой /api/v1/help/,
ALLOWED_HOSTS без вендорских хостов, CI environment url — из CHATBALLS_APP_DOMAIN
- DEFAULT_FROM_EMAIL по умолчанию no-reply@localhost; JWT-аудитория support-токена
chatballs.support; загрузчик виджета: window.ChatballsChat, события chatballs-chat-*
- «Контакты»: продукты берутся из данных организации ({code, name}), цвет —
детерминированно по коду; зашитый список продуктов вендора убран
- npm-скоупы @chatballs/*, тема chatballsTheme/buildTheme
- тестовый bootstrap: организация demo, сотрудник staff.member@example.org,
продукты site/app; фикстуры на example.com
- убрана мёртвая привязка Shell к e-mail сотрудника вендора
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
- каталог code/custocrm → code/chatballs; CLI custocrm → chatballs; пакеты
hub_platform → chatballs, hub_backend → chatballs_backend (app labels прежние)
- переменные CUS_* и CUSTOCRM_* → CHATBALLS_*; образы chatballs-*; compose-проект
и база chatballs (были edevs_hub); роли Postgres chatballs_* (были custocrm_*);
схема RLS chatballs и GUC chatballs.organization_id; cookie chatballs_*
- CI: APP_DIR code/chatballs, DEPLOY_ROOT /opt/chatballs
- deploy/migrate/rename-to-chatballs.sh — миграция существующей установки без
потери данных: остановка старого проекта, .env (с резервной копией),
переименование суперпользователя initdb через временную роль, остальных ролей,
базы, схемы и функции RLS; проверено на локальном стеке
- dev-стек хранит Postgres в bind-mount data/postgres, как prod (именованный том
compose.dev был устаревшим снимком и вводил в заблуждение)
- снятие демо удаляет объекты, созданные поверх демо-данных тестировавшим
(звонки по демо-диалогу), вместо падения на PROTECT
- реальные домены *.custocrm.ru и идентификаторы документов не тронуты
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
- LogoIcon: два круга и перемычка с вырезами, fill currentColor; в сайдбаре,
рейке, экране входа/мастера, портале помощи — без акцентной/тёмной подложки,
цветом текста темы (--n-1); favicon без фона, цвет по prefers-color-scheme
- видимое имя сервиса — Chatballs (заголовки, тексты, title); внутренние ключи
(localStorage, postMessage, id виджета) пока прежние — переименование проекта позже
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Виджет записывает голосовое (MediaRecorder, зеркало хука internal-ui) и
шлёт multipart в /webchat/messages/; входящее байтами — voice_content в
InboundMessage, скачивание не нужно. Аудио отдаётся по токену сессии
(/webchat/messages/<id>/audio/), в пузыре — нативный плеер. Оператор
может отправлять голосовые и в WEB-канал (доставка поллингом виджета).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- launcher: круглая кнопка → пилюля с иконкой Tabler brand-hipchat и словом
«Чат» (inline SVG — на сайте продукта шрифта Tabler нет), hover/focus-visible,
анимации bump/shake переделаны под новую форму
- окно 384x600 → 440x720
- фикс: панель открывалась на начале истории. Лоадер держит iframe скрытым
(display:none), у ленты scrollHeight === 0 и автоскролл не срабатывал —
виджет теперь слушает edevs-chat-opened и доскроллит после показа, лоадер
повторяет сигнал на load iframe
- лента ближе к мессенджеру: время у сообщений, растущее под текст поле ввода,
крупнее типографика и шире пузыри
- support-режим переиспользует шапку/пузыри/composer из ChatView вместо своих копий
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
On the client call page an incoming invite never showed Принять — only
Отменить. resolveAudioCallViewMode returned "ringing" for REQUESTED/RINGING
before start, but ringing renders a single Отменить button. An invite is an
incoming call for the client (the staff initiates it), so it must be incoming
which renders the Принять/Отклонить pair. AudioCallStage now also passes
onDecline, which the incoming PairBar needs.
The call page also missed the design tokens that audio-call.css consumes
(--font-sans, --radius-4xl/pill/lg, --success/--error): web-chat never defined
them, so the audio scene fell back to inherited system font and a 28px radius.
Added the tokens to web-chat styles.css :root straight from the baseline
(typography/effects tokens); no external webfont per ADR-HUB-0013.
CallApp returned <AudioCallStage> before the bootstrap effect ran: on first
render call was null, so call?.kind !== 'VIDEO' was always true and the audio
scene rendered with empty call/accessToken/iceServers — RTC never started and
the screen stayed idle. The invite banner and the call page also checked kind
inversely (kind !== 'AUDIO' vs kind !== 'VIDEO'), diverging whenever kind was
undefined, so the widget labelled an underdetermined call as video.
- api.ts: single isVideoCall(call) helper (default AUDIO); used by both the
invite banner and the call page so they agree on the kind.
- CallApp: composition-only dispatcher — bootstrap (invite/access token,
polling) runs unconditionally, all hooks called, branching only in JSX by
isVideoCall. No early return from the middle of the hook body.
- VideoCallStage: video path factored out of CallApp (symmetric to
AudioCallStage), reusing the loaded call/access/iceServers.
- useConnectionTimer: shared connection timer; removes the duplicate from
AudioCallStage.
Introduce a call kind (AUDIO default, VIDEO) so dialogs support both audio
and video calls. Video path is unchanged; audio is a separate full-screen
component built 1:1 on the «Аудиозвонок.dc.html» baseline.
Backend
- CallSession.kind (CallKind AUDIO|VIDEO, default AUDIO) + migration 0005
- create_call_request(kind), CallCreateView validates kind from request body
- serializers expose kind in call_payload, public_invite_payload,
public_call_state_payload (drop stale hardcoded capabilities)
- timeline system message reflects kind («аудио-/видеозвонок»)
- tests updated for the kind field and new message wording
@edevs/ui
- useCallRtcSession gains videoEnabled (default true; video unchanged);
audio requests audio-only getUserMedia, no camera fallback
- AudioCallView, AudioCallWaveform (live AnalyserNode oscilloscope),
AudioCallIcons (mic/speaker/phone/status), audioCallStates, audio-call.css
- exported via index.ts
internal-ui
- two call buttons in the conversation header (audio + video)
- CallOverlay becomes a kind dispatcher: VideoCallOverlay (unchanged) /
AudioCallOverlay; kind threaded through model/hook/workspace
web-chat
- CallInfo.kind; invite banner shows audio vs video title and icon
- CallApp dispatches to AudioCallStage for audio; audio resolve/build in model
Infra
- compose.yaml: widen Coturn relay UDP range to 49160-49999 (~420 relay
calls) with explanatory comment
- New logo: favicon.svg and LogoIcon (replaces PulseIcon) in sidebar and auth screens
- Source logo asset saved to resources/icon.svg
- Replace "Edevs Hub" with "CustoCRM" across internal-ui, web-chat, backend strings (email sender/subject, TOTP issuer, notification bot texts, OpenAPI title), env examples and README
- Add .claude/launch.json for internal-ui dev server (bind 127.0.0.1)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- .hub-call-avatar: padding-bottom 0.1em компенсирует ассиметрию метрик
Segoe UI (ink уходил на ~1.5px ниже центра круга при flex-центрировании)
- @media <=480px: клиентская вьюха звонка разворачивается на весь экран
(height 100dvh, media flex:1, aspect-ratio снят) вместо squat 16:9
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- username из TG/MAX сохраняется в ConnectionIdentity и виден во вкладке «Клиент»
- кнопка «Запросить контакт»: TG/MAX — кнопка «Поделиться контактом» в чате бота,
Web — форма телефона с маской в виджете; телефон пишется в Contact.phone
- приём контакта без AI-хода: сообщение kind=contact + подтверждение
(в TG со снятием reply-клавиатуры)
- POST /conversations/<id>/request-contact/ и POST /webchat/contact/
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
SPEC-HUB-0010 §7.2: authenticated in-product support chat.
- api.ts: startSupportSession (verify Product Support Token → conversation +
widget-credential), pollSupport/sendSupport по widget-credential.
- SupportApp.tsx: support-режим виджета — нет consent/lead form, нет полей
имя/email/purchase; старт по токену один раз; приветствие «Здравствуйте,
<displayName>»; poll/send через widget-credential; unavailable при истечении
токена. Переиспользует Bubble/Typing/Header pattern sales-виджета.
- main.tsx: режим по ?mode (sales default, support по data-mode в loader).
- App.tsx (sales) не тронут — минимум риска.
Web Chat is a WEB connection of a processing channel (SPEC-0003/ADR-0012),
reusing the whole contour: a public widget loads from the hub domain via one
async tag (<script src=hub.edevs.tech/chat-widget.js data-channel=...>), opens
an isolated iframe panel. Backend webchat app: public config/session/messages
endpoints (anonymous session, hash-at-rest token); inbound goes through the same
ingest (AI release + retrieval + handoff + notifications); WEB transport send is
a no-op (browser polls). Panel rebuilt 1:1 from the baseline (welcome+consent /
ai / operator / unavailable, quick replies, typing). nginx serves /chat-widget.js
and /chat/ from the hub domain; demo host page at /chat/demo.html. Verified
end-to-end: config→session→message→AI reply→poll.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>