«Удалить диалог» удаляет: переписку, вложения, звонки и уведомления о нём. Право только у владельца и администратора, писать в удалённый диалог некуда, лента виджета у клиента обнуляется по признаку 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>
Утечка камеры и микрофона у оператора:
- терминальный статус приходит и поллингом состояния, а не только по RTC-сокету
(до «Присоединиться» сокет ещё не открыт), поэтому `onCallState` → `stop()` не
срабатывал. Экран уходил в `status`, а оба выхода с него RTC не глушили:
`endAndClose` для этого режима пропускает `finish()`, а «Закрыть» зовёт
`close()`, который только прячет модалку. Оверлей при этом остаётся
смонтированным, так что и cleanup хука не отрабатывал — камера оператора
горела до переключения диалога. Оба оверлея явно освобождают устройства,
когда оверлей закрыт или звонок завершён.
call-view.css переведён на токены дизайн-системы:
- радиус карточки 24px → `--radius-4xl` (14px), тень → `--shadow-lg`,
рамки → `--border-default`, второстепенный текст → `--text-secondary`,
круглые кнопки → `--n-7`/`--n-9`/`--n-3`, выключенные → `--error-*`,
таймер → `--error-bg`/`--error-text` + 12.5px и моноширинный шрифт,
таймер переподключения → `--warning-text` и точка `--warning`,
кнопки precall → `--radius-lg` (9px), 14px и `--shadow-primary`;
- радиус контейнера модалки взят из того же токена: снаружи виден именно он;
- хардкод оставлен там, где он есть и в макете — сцена видео и слои поверх неё
от темы не зависят.
Побочный эффект: карточка звонка у оператора теперь следует тёмной теме
internal-ui, как остальные поверхности. Проверено в обеих темах.
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>
Первичное требование: поднял докер — прошёл мастер — дальше всё в интерфейсе.
Ни одной переменной окружения задавать не нужно и негде.
Запуск и секреты:
- .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>
- нет зашитых доменов: 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>
- поле ui_theme (светлая/тёмная/системная) и ui_accent (HEX) на пользователе,
эндпоинт /auth/profile/appearance/, значения в session-payload;
- применение через data-theme на <html> и переменную --primary; производные
оттенки (hover/bg/border/text) считаются color-mix'ом и работают в обеих
темах с любым акцентом; antd переключается на darkAlgorithm;
- полный dark-набор токенов из дизайн-базлайна v2 в base.css (нейтрали,
поверхности, статусные полутона, тени) + токены surface-*/border-strong;
- массовая токенизация: ~1050 захардкоженных цветов в 45+ CSS-файлах и все
TSX-словари статусов переведены на var(--…); осознанно остались белый текст
на насыщенных фонах, QR-код, фирменные цвета каналов, аватары, iframe писем;
- карточка «Внешний вид» в настройках: три темы, четыре пресета акцента из
макета и произвольный HEX.
tsc 0, vitest 67 OK, build OK; backend-прогон зелёный (2 падения — артефакт
параллельного редактирования, воспроизведены и закрыты следующим прогоном).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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
Прошлый padding-bottom двигал буквы вверх — направление было неверным. По
факту flex ставит baseline в центр круга, а заглавные без нижних выносных
сидят на baseline и уходят вверх на ~0.37em. Сдвиг вниз padding-top:0.75em
(замерено попиксельно по факту рендера: big +0.25px, small -0.25px).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- поток собеседника монтируется всё время активного звонка, а не только при
включённой камере — иначе в аудиозвонке (камера выключена) не было звука
- :fullscreen раскладывает карточку колонкой (media flex:1) — нижняя панель
кнопок больше не уходит за край экрана
- кнопка полного экрана стала тумблером; крестик в полноэкранном режиме
сначала выходит из fullscreen, а не завершает звонок
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- CallMetric: тип соединения DIRECT/RELAY/UNKNOWN + категория ICE-кандидата
(host/srflx/prflx/relay) и RTT; храним только КАТЕГОРИЮ, без адресов/SDP/ICE
- record_call_metric: derive connection_type, whitelist кандидатов, clamp RTT,
идемпотентный upsert по (call_session, side)
- signaling/consumer: тип participant.metrics — сохраняем, не ретранслируем
и не логируем; call_payload отдаёт metrics для internal-ui/аналитики E15
- callRtc.ts: на connected снимает getStats выбранной candidate-pair и шлёт
только категорию кандидата + RTT (подтверждает direct vs TURN relay)
- tests: derive/relay/unknown, санитизация мусора и RTT, upsert, payload
Co-Authored-By: Claude Opus 4.8 <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>
Public checkout belongs to product backends, not Hub (ADR-HUB-0014,
ADR-HUB-0018). Delete apps/checkout and its wiring: compose service, nginx
pay.localhost route, npm workspace and dev script, playwright project,
HubApplication type, check.ps1 typecheck and README mentions.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>