287 Commits
Author SHA1 Message Date
Andrey 34afd9569f 🐛 fix(demo): покрыть новые модели веб-чата 2026-10-01 19:23:13 +03:00
Andrey 29e2232fc3 ✨ feat(webchat): передавать поля сайта через лоадер и виджет 2026-10-01 12:42:41 +03:00
Andrey b160335bf6 ✨ feat(webchat): применять оформление кнопки из конфигурации 2026-10-01 00:27:29 +03:00
Andrey a940dcc2e3 ✨ feat(webchat): события и обновление данных сайта в диалогах 2026-09-30 18:59:34 +03:00
Andrey 416e37e441 ✨ feat(webchat): настроить форму перед чатом на сервере 2026-09-30 17:29:45 +03:00
Andrey 6e36bdc725 ✨ feat(ai): учитывать данные сайта в промпте агента 2026-09-30 17:25:46 +03:00
Andrey c3ea8395d0 ✨ feat(webchat): сохранять данные сайта в контакте 2026-09-30 09:34:11 +03:00
Andrey 21a7626f95 ✨ feat(webchat): настройки оформления виджета на сервере
config.appearance подключения: цвет #RRGGBB, положение, размер 48/56/64,
форма и иконки кнопки, свой CSS до 10 КБ. Из CSS вырезаются @import,
внешние url() и expression. Публичная конфигурация отдаёт appearance,
config.accent продолжает работать; конфигурация не кэшируется, лоадер —
не дольше 5 минут.
2026-09-29 12:47:05 +03:00
Andrey 0f01a8ce47 ✨ feat(web-chat): схема своих полей в веб-подключении
config.fields WEB-подключения проверяется при сохранении: формат и
уникальность ключа, запрет смены ключа (по служебному id поля), название
до 60 символов, тип из списка, значения только у списка, не больше 30
полей, ключи name/email/phone заняты. Ошибки — через t(). Публичная
конфигурация виджета отдаёт схему без aiVisible.
2026-09-29 12:33:53 +03:00
Andrey 811a164c44 ✨ feat(web-chat): загрузка иконок виджета с очисткой SVG
POST /api/v1/organizations/{id}/integrations/{id}/assets/ принимает SVG или
PNG до 256 КБ (PNG не меньше 96×96) и возвращает { url }. SVG очищается до
сохранения; файл лежит в хранилище организации на диске или в S3 и
отдаётся виджету без сессии по непредсказуемому UUID.
2026-09-29 12:32:53 +03:00
AndreyandClaude Opus 5.5 8f3b1e43a4 🐛 fix(web-chat): на телефоне окно чата на весь экран
На экранах до 480px лоадер раскрывает панель на весь экран без
скруглений и прячет круглую кнопку, пока панель открыта: иначе она
висит поверх поля ввода. Кнопку «Развернуть окно» панель не показывает —
разворачивать некуда. Ширину экрана iframe не видит, поэтому режим
сообщает лоадер (chatballs-chat-layout), а панель после монтирования
сама его запрашивает.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 21:27:59 +03:00
Andrey 7e0a12a2c9 ♻️ refactor: убрать устаревшие упоминания Hub и CustoAI 2026-09-24 18:02:27 +03:00
Andrey df9c6ad6fd 🚨 fix(lint): порядок импортов портала 2026-09-24 14:08:04 +03:00
Andrey 8c618691f6 ✨ feat(portals): навигация и связанные статьи 2026-09-24 14:05:21 +03:00
AndreyandClaude Opus 5.5 64266dcc9c ✨ feat(settings): шаблоны ответов в настройках и переменные в тексте
Раздел «Настройки → Шаблоны ответов»: список по образцу «Групп», создание
кнопкой в шапке раздела, правка и удаление окнами, как у интеграций. Без права
на настройки список только читается.

В тексте шаблона можно использовать {{client_name}}, {{operator_name}} и
{{company}}; кнопка «Вставить переменную» ставит код в позицию курсора.
Композер подставляет значения при выборе шаблона. Переменная без значения
остаётся в тексте, над полем — предупреждение, отправка заблокирована. Гость
виджета распознаётся по подписи сессии (isGuest в карточке диалога), его
«Гость · код» за имя не считается. Сервер отклоняет неизвестные переменные.

Переименование шаблона в занятое название теперь отвечает 409, а не падает
на ограничении базы. Логика шаблонов вынесена из Composer в
useComposerTemplates и ComposerTemplatesMenu.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 10:05:32 +03:00
Andrey 556246865e 🚨 fix(lint): исправить сортировку импортов 2026-09-22 07:29:03 +03:00
Andrey 0d307c7d06 🐛 fix(ai): сбрасывать circuit breaker после настройки провайдера
Повторы одного вызова теперь считаются одним логическим сбоем, поэтому предохранитель не открывается посреди второго сообщения. Изменение runtime-конфигурации и успешная проверка увеличивают ревизию интеграции: каждый event-worker заменяет открытый breaker при следующем запросе.

Удаление используемого провайдера возвращает 409 с понятной причиной и не пишет ложное событие об успешном удалении. Добавлены миграция и регрессионные тесты.
2026-09-22 07:13:20 +03:00
AndreyandClaude Opus 5 bcb66fadbc 🐛 fix(webchat): первое открытие чата — с анимацией и кнопкой «свернуть»
Первый кадр силуэта рисовался раньше, чем создавался слой, на котором он
живёт. Обращение к нему падало, и остаток обработчика не выполнялся: окно
появлялось рывком, а кнопка оставалась со знаком агента вместо шеврона —
свернуть чат было нечем. Со второго раза слой уже существовал, и всё
работало, поэтому дефект был виден только при первом открытии.

Слой создаётся вместе с панелью, до первого кадра.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-20 10:49:00 +03:00
AndreyandClaude Opus 5 1f46ed62bd 💄 feat(webchat): виджет по утверждённому образцу и разворот окна
Виджет переложен по образцу, который дал владелец.

Лента: имя и аватар агента стоят над пузырём, у пузыря не скруглён верхний
угол со стороны автора — «хвостик». Аватар агента — знак робота на круге
цвета акцента; он же стоит в шапке вместо буквы, пока у виджета нет способа
получить логотип компании, и он же на кнопке чата вместо прежней подписи.
Пока ответ считается, в ленте висит «печатает» с бегущими точками.

Поле ввода: без рамки, скрепка слева, справа запись голосового, которая
сменяется отправкой, как только клиент начал печатать. При росте поля кнопки
держатся верхней строки. Подсказки убраны. Подвал — одного тона с лентой,
внизу «Работает на Chatballs» со ссылкой на сайт.

В шапке появилась кнопка разворота: окно становится почти вдвое шире и
немного выше. Размер держит лоадер на сайте-хосте — панель живёт в iframe и
сама себя не растянет.

Появление и исчезновение — эффект джина, как в доке macOS: окно вытягивается,
изгибается горловиной и втягивается в кнопку. Аффинными преобразованиями
такое не делается, поэтому силуэт считается контуром SVG покадрово, тем же
контуром режется контейнер панели, а само окно внутри получает матрицу,
построенную по этому силуэту, — вместе с окном деформируется и переписка.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-20 10:35:29 +03:00
AndreyandClaude Opus 5 83b1119d6b ✨ feat(conversations): ответ AI считается отдельно от приёма входящих
Ход AI выполнялся прямо в приёме сообщения: цикл опроса мессенджеров и
HTTP-запрос виджета ждали провайдера, держа открытой транзакцию организации.
Один ход — это два обращения к модели (эмбеддинг и чат) по тридцать секунд с
двумя повторами, то есть до трёх минут, и всё это время ни одно входящее по
всей установке не забиралось. Владелец видел это как «бот залипает»: сайт и
MAX на одном агенте отвечали с задержками или молчали.

Приём теперь доводит дело до записи сообщения и ставит событие
`conversation.ai_turn_requested`. Ход считает роль событий воркера
(`run_worker --role=events`) короткими транзакциями, между которыми остаются
походы к провайдеру и в мессенджер. Туда же уехала расшифровка голосовых —
последнее обращение наружу из цикла опроса.

Воркер разделён на роли: `poller` опрашивает подключения и ведёт периодические
работы (один экземпляр — курсоры и паузы после сбоя живут в его памяти),
`events` разбирает outbox и масштабируется репликами (`CHATBALLS_EVENT_WORKERS`,
по умолчанию две). Роль `all` осталась для разработки.

Очередь событий научилась двум вещам: события одного диалога не выдаются
параллельно (иначе два ответа приезжают клиенту вперемешку) и событие,
взятое упавшим процессом, возвращается в очередь по истечении аренды.

Попутно убраны мины, которые тот же залип и продлевали:
- ход клиенту ограничен своим таймаутом (CHATBALLS_AI_TURN_TIMEOUT, 20 с)
  и сроком годности (CHATBALLS_AI_TURN_DEADLINE_SECONDS, 120 с) — просроченный
  ход не зовёт модель, а передаёт диалог оператору;
- отказ провайдера по существу запроса (4xx, кроме 429) больше не повторяется
  трижды по таймауту;
- предохранитель провайдера считает сбои по ключу «организация + интеграция»,
  а не один на процесс: отозванный ключ одной организации гасил AI у всех;
- потолок паузы после сбоя опроса — минута вместо четверти часа: он был
  компромиссом ради журнала однопоточного воркера.

Виджет узнаёт, что ответ считается, по признаку `thinking` в ленте.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-20 10:35:00 +03:00
AndreyandClaude Opus 5 b95a4edf6e 🐛 fix(settings): адрес установки помнит порт, ссылки ведут на него
Мастер первого запуска и «Настройки» отбрасывали порт из адреса. На
установке, опубликованной как ip:8081, ссылки на файлы, звонки, приглашения
и сброс пароля уходили без порта и вели в пустоту.

Порт хранится отдельно от хоста (InstanceSettings.public_port): по хосту
проверяются входящие Host, строятся домены порталов и адреса TURN, и порт им
чужой. В ссылки он добавляется, когда не совпадает с портом схемы. В поле
адреса его вписывают через двоеточие; смена одного порта прежний адрес не
сдвигает.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 11:32:46 +03:00
AndreyandClaude Opus 5 19448eb05a ✨ feat(ai): размер контекста диалога задаётся в карточке агента
Модель получала последние 20 сообщений диалога — число было зашито в
ingest.py, и поменять его можно было только правкой кода, которую стирало
первое же обновление. Длинному разговору 20 мало, локальной модели с
маленьким окном — бывает много.

Теперь это поле «Контекст диалога, сообщений» в блоке «Модель» карточки
агента: от 1 до 200, по умолчанию 20. Проверочный чат карточки видит то же
окно, что и живой диалог.

Попутно история выбирается в базе с конца и с ограничением: раньше диалог
целиком поднимался в память ради последних сообщений.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 11:32:46 +03:00
AndreyandClaude Opus 5 fed079e02d ✨ feat(notifications): открытый диалог гасит свои уведомления
Прочтением считалось только нажатие на строку в шторке. Сотрудник мог уже
отвечать клиенту, а оклик «клиент ждёт» по этому же диалогу продолжал висеть
непрочитанным и держать счётчик в шапке. Счётчик переставал что-либо значить:
он звал туда, где человек уже был.

Теперь при открытии диалога гасятся все уведомления о нём — не только те,
что клиент успел загрузить в шторку: в неё помещается полсотни последних, и
гасить по загруженному списку означало бы оставлять хвост. Отбор идёт по
адресу уведомления, включая маршруты, которыми на диалог ссылались старые
строки в базе.

Отметка прочтения теперь шлёт сотруднику событие: счётчик живёт в шапке
каждой открытой вкладки, и без события они разъезжались до следующего опроса.

Проверено: тесты уведомлений (гасится только свой диалог, гасятся все его
уведомления, старый маршрут узнаётся, нечисловой идентификатор отклоняется),
рассылка событий, каталоги переводов, ruff, проверка типов рабочего места.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 11:55:01 +03:00
AndreyandClaude Opus 5 31985fc35c ✨ feat(vk): канал ВКонтакте — сообщество как точка входа
Сообщения сообщества ВКонтакте принимаются через Bots Long Poll: тот же
способ, что у Telegram и MAX, поэтому подключению не нужен ни публичный
адрес, ни доступ извне — установка за NAT работает наравне с остальными.

Позиция потока живёт в poll_marker, адрес сервера и ключ — в памяти
процесса: их выдают на несколько часов, и колонка под них означала бы
запись в базу на каждом цикле опроса. Ответы failed 1-3 восстанавливаются
в том же цикле, иначе подключение висело бы с протухшим ключом до
перезапуска воркера.

Идентификатор сообщества владелец не вводит: его называет сам ключ
доступа, и проверка подключения кладёт его в конфигурацию — как имя бота
у Telegram и MAX. Проверка заодно смотрит настройки сообщества: без Long
Poll и события о входящем сообщении приём невозможен, и об этом честнее
сказать сразу, а не молчать зелёным статусом. Настройки чужого сообщества
при этом не меняются. Нехватка прав у ключа объясняется словами: ВКонтакте
отвечает на неё английским «no access», из которого не видно, что включить.

Имя, логин и фото отправителя ВКонтакте в апдейте не присылает — их
забирает один users.get на пачку сообщений, а не на каждое: на оживлённом
сообществе запрос на реплику упёрся бы в частоту обращений.

Телефона и кнопки «поделиться контактом» у ВКонтакте нет, поэтому просьба
уходит обычным сообщением, как и почтой. Голосовые принимаются, но не
отправляются: провайдер ждёт ogg/opus, а композер пишет webm.

Токен уходит строкой запроса — заголовка авторизации у ВКонтакте нет.
Поэтому адрес больше нигде не печатается как есть: и журнал, и поле
последней ошибки подключения, которое видно в интерфейсе, проходят через
маскирование секретных параметров.

Проверено на живом сообществе: проверка подключения, опрос Long Poll,
приём текста, голосового и фото с подстановкой имени, логина и аватара.
Тесты транспорта и проверки подключения, каталоги переводов, ruff из
корня, tsc и vitest по internal-ui.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 11:02:20 +03:00
AndreyandClaude Opus 5 ae46508d22 ✨ feat(ai): у агента своя модель для текста и своя для расшифровки
Ключ провайдера один на организацию, а агентов на нём несколько, и модель им нужна разная. Раньше модель принадлежала интеграции и копировалась на агента при каждом сохранении — выбрать её агенту было нельзя. Теперь на карточке агента две пары «провайдер + модель»: чем отвечать и чем расшифровывать голосовые. Пустое поле модели означает «как в интеграции», и подсказка в нём показывает её значение, поэтому агент, которому модель не выбирали, по-прежнему следует за настройкой ключа.

Миграция освобождает поле у тех агентов, чья модель совпадает с моделью их интеграции: такие агенты продолжают следовать за ней, а не фиксируют старое значение.

Проверено: тесты маршрутизации моделей и провайдеров, карточки агента, расшифровки, списка агентов и каталога переводов; ruff и проверка типов.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 08:40:04 +03:00
AndreyandClaude Opus 5 e35133b159 🐛 fix(integrations): продукт представляется своим именем, отказ объясняется словами
Исходящие запросы уходили с умолчанием urllib — «Python-urllib/3.12». Защита перед чужим API такой запрос банит раньше, чем его увидит сам провайдер: на боевом стенде Groq через рабочий прокси отвечал 403 «error code: 1010», а с обычным User-Agent тот же запрос и тот же прокси проходят. Теперь opener представляется как «Chatballs/<версия>», свой User-Agent конкретного запроса он не перебивает.

Проверка подключения больше не показывает голый код. Причину, если провайдер её назвал, видно сразу («Invalid API Key»), HTML-страница защиты сжимается в строку, молчаливый 401/403 отправляет смотреть ключ и доступность провайдера из сети. Ответ целиком уходит в журнал.

Проверено на боевом стенде: без User-Agent — 403 «error code: 1010», с любым User-Agent — ответ самого Groq. Пять новых тестов, тесты интеграций, транспортов, провайдеров и i18n.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 08:07:33 +03:00
AndreyandClaude Opus 5 65f7de6bc3 ✨ feat(ai): расшифровка голосовых отдельным провайдером
Модель, которой агент отвечает, не обязана уметь речь в текст: у Anthropic и Yandex Foundation Models эндпоинта /audio/transcriptions нет вовсе, и голосовые у такого агента расшифровать было нечем. На карточке агента появился выбор «Расшифровка голосовых»: по умолчанию «Как у ответов», иначе любая другая интеграция организации — модель берётся из её поля «Модель расшифровки голосовых».

Ошибка расшифровки больше не вываливает оператору сырой ответ чужого API. В ленте — фраза о следствии и о том, где чинить: отказ в доступе отправляет к ключу и модели, отсутствующий эндпоинт — к выбору интеграции для расшифровки. Сам ответ провайдера уходит в журнал.

Проверено: пять тестов маршрутизации и текста ошибки, тесты карточки агента, голосовых и провайдеров, ruff и проверка типов рабочего места.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 07:33:11 +03:00
AndreyandClaude Opus 5 e5d3e98ce3 🎨 style: порядок импортов по конфигу репозитория
Ruff в CI читает pyproject.toml из корня и сортирует импорты; локальный прогон
из каталога backend этого правила не видел.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 06:56:38 +03:00
AndreyandClaude Opus 5 e8155db4f9 🐛 fix(max): достаём сообщение, когда MAX присылает событие без тела
По журналу с боевого: на голосовое MAX присылает message_created, в котором `message` отсутствует целиком — разбирать нечего, и реплика клиента пропадала. Теперь на такое событие транспорт забирает последние сообщения тех чатов, где что-то происходило рядом с ним, и прогоняет их через обычный разбор. Повторы безвредны: входящие отсеиваются по идентификатору сообщения.

Проверено: два новых теста транспорта (с телом — лишних запросов нет, без тела — сообщение доезжает), тесты разбора и backoff.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 06:41:44 +03:00
AndreyandClaude Opus 5 d9bc540bc3 ✨ feat(calls): relay работает сразу после установки, без настройки
Звонок через симметричный NAT или VPN без relay не соединяется — значит relay должен стоять из коробки, а не быть отдельным профилем с выделенным IP и сертификатом. Coturn поднимается вместе со стеком на том же адресе: порт 3478 не спорит с 80 и 443, TURN-over-TLS выключен, потому что он и требовал второго адреса.

Адреса relay и STUN считаются от адреса установки и появляются в настройках сами; вписанное владельцем по-прежнему побеждает. Deploy и doctor больше не требуют второго публичного адреса, README обоих языков переписан: вместо «опционально, выделенный IP» — какие порты открыть.

Проверено: тесты TURN (включая четыре новых на автонастройку), тесты API звонков и адреса установки.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 06:38:47 +03:00
AndreyandClaude Opus 5 73ed0ae6cd 🐛 fix(chat): бейдж «Чат» считает только видимые живые диалоги
Число на пункте «Чат» — это очередь к оператору. Считалось оно по всей организации и вместе с архивными диалогами: сотрудник видел в бейдже то, чего нет в его списках, и до нуля счётчик не опускался. Теперь граница та же, что у счётчиков вкладок списка: видимость сотрудника и без архивных.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 00:47:16 +03:00
AndreyandClaude Opus 5 8d334de3f3 🐛 fix: завершение звонка, фото контактов и голосовые из MAX
Кнопка «Завершить» у оператора обязана заканчивать звонок из любой живой фазы. Отмена умела только REQUESTED/RINGING, и после того как клиент принял вызов, а соединение не установилось (обычное дело за NAT), оператор получал 409 и звонок висел. Фазу выбирает finish_call — тот же код, что и на стороне клиента; в интерфейсе кнопка больше не молчит при ошибке, а заканчивает звонок вторым путём.

Фото контакта из Telegram и MAX скачивается и хранится у нас, а отдаётся своим адресом: страница рабочего места живёт под CSP «img-src 'self'», и ссылка на CDN мессенджера до экрана не доезжала — оператор видел инициалы. Источник запоминается, поэтому фото качается один раз; у Telegram оно спрашивается отдельным запросом, которого в апдейте нет.

Голосовое из MAX с незнакомой формой вложения больше не пропадает: раньше такое сообщение уходило в никуда, теперь оператор видит его заглушкой, а в журнал попадает сам payload. В журнал же пишется причина, по которой диалог сразу уходит в очередь: у канала нет активного AI-агента.

Проверено: 83 теста звонков, 6 новых тестов фото контакта, тесты ingest, вложений и голосовых, typecheck рабочего места.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 00:44:35 +03:00
AndreyandClaude Opus 5 733b44f186 ✨ feat: удаление диалога, дерево знаний и перетаскиваемый онбординг
«Удалить диалог» удаляет: переписку, вложения, звонки и уведомления о нём. Право только у владельца и администратора, писать в удалённый диалог некуда, лента виджета у клиента обнуляется по признаку reset в поллинге.

База знаний: категории слева сворачиваются и разворачиваются (свёрнутое помнит браузер), в подвале появился выбор «На странице» 20/50/100 — общий для всех списков компонент.

Окно «Знания» карточки агента разложено по категориям и порталам со счётчиками и «Выбрать все»; набор материалов грузится целиком, а не первой страницей.

Пилюля онбординга перетаскивается и помнит место. Голосовое в веб-чате нарисовано плеером оператора без расшифровки, рисунок волны вынесен в общий пакет. Убрана горизонтальная полоса прокрутки в меню меток.

Проверено: typecheck internal-ui и web-chat, backend-тесты диалогов, виджета и i18n.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 23:59:28 +03:00
Andrey ee8367e205 🐛 fix: создавать категории при импорте и восстановить добавление меток
Создавать недостающие уровни categoryPath в транзакции документа и повторно использовать существующие категории. Разрешить импорт новых путей в предпросмотре, обновить переводы и baseline.

Исправить конфликт ширины кнопки создания метки со стилями меню. Выделить метки, заметку и план импорта в отдельные компоненты. Проверено: 13 backend-тестов, 7 frontend-тестов, сборка и браузерный предпросмотр файла из 66 документов.
2026-09-14 22:36:35 +03:00
AndreyandClaude Opus 5 234a3e43da 📝 fix(i18n): убрать упоминания лимитов из текстов интерфейса
Тарифные лимиты сняты (ADR-CHATBALLS-0042), дневного бюджета в коде нет,
но два текста продолжали о них говорить.

Уведомление AI_STOPPED шлётся только при ошибке провайдера — подпись
«или лимитом» вводила в заблуждение. Подсказка первого шага онбординга
обещала учёт лимитов, которого не существует: расходы считает провайдер
по своему тарифу.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 06:34:03 +03:00
AndreyandClaude Opus 5 3c01486aa3 🐛 fix(presence): isinstance через union — на этом падал ruff в CI
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 02:47:09 +03:00
AndreyandClaude Opus 5 5d8362e8f6 ✨ feat(queue): очередь к оператору, эскалация и уведомления по макету
Очередь помнит, с какого момента ждёт клиент (waiting_since), и «дольше всех
ждущий» больше не считается по последнему сообщению: клиент, напомнивший о
себе, уезжал в конец очереди. Постановка в очередь сведена в одно место
(conversations.queue) вместо шести копий одного правила.

Уведомление видно тем же, кому виден диалог: раньше оно фильтровалось только
правом, и оператор чужой группы получал оклик с именем клиента и куском
переписки по диалогу, который не может открыть. Получатели считаются одним
запросом вместо запроса на каждого сотрудника.

Уведомления доезжают событием по сокету, а не следующим опросом: сокет поднят
на уровень оболочки и работает на любом экране. Появились уведомления самого
браузера — без service worker и Web Push, чтобы не тащить на self-hosted
зависимость от чужого push-сервиса.

Настройка «о чём звать» стала одна на все транспорты (NotificationPreference),
набор событий приведён к макету: «новый диалог» и «клиент запросил оператора»
разделены, добавлены «назначили на меня» и «долго ждёт человека».

Забытый диалог больше не тонет в тишине: свип напоминает группе, потом всем,
потом руководству; сроки — настройка организации в новом разделе «Когда звать
на помощь». Назначение стало осмысленным актом — назначенного зовут лично, у
него есть срок, и не взял — диалог возвращается всем.

Присутствие берётся из открытого сокета и подсказывает двум местам: очереди —
что напоминать некому, и выбору ответственного — кто сейчас за рабочим местом.

Макет: design/baseline/Очередь и уведомления.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 01:49:52 +03:00
AndreyandClaude Opus 5 42a468ec97 🐛 fix(portals): базовый домен порталов — адрес установки, а не localhost
Суффикс приходил из переменной окружения с умолчанием localhost, а задать её
в коробке негде: у продукта нет .env, и production-манифест её не передаёт.
На живой установке диалог создания портала предлагал help.localhost — адрес,
который не резолвится и не получает сертификат. Источник теперь тот же, что у
IPv4 для A-записи: адрес из мастера первого запуска. Переменная осталась
переопределением для контуров с собственной конфигурацией.

Пока установку открывают по IP, домена нет вовсе: hosted-адрес не собирается,
диалог говорит об этом и не даёт создать портал вместо выдачи мёртвой ссылки.
Порталы, успевшие осесть на .localhost, миграция переводит на домен установки —
такие ссылки всё равно никуда не вели.

Заодно поле адреса в диалоге: без color и background оно красилось системным
белым и светилось в тёмной теме. Скин по базлайну — 36px, текст --n-1, суффикс
на --n-9.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 00:11:06 +03:00
AndreyandClaude Opus 5 09fb0177b1 🔥 refactor(ai): убрать учёт расхода на модель и валюту организации
Учёт стоимости вызовов остался без потребителя, когда сняли лимиты, и сам
по себе не работал: цену присылает только OpenRouter, для остальных
провайдеров была прайс-таблица из двух моделей и ноль для всех прочих.
Ни одна цифра расхода в интерфейсе не показывалась. Удалены ai/pricing.py,
ai/limits.py, поля LlmInvocation.cost_micros и currency, статус BLOCKED
вместе со строками журнала, ChatResult.cost_micros и чтение usage.cost,
настройки CHATBALLS_AI_PRICING и CHATBALLS_AI_GLOBAL_DAILY_COST_LIMIT_MICROS,
поля aiCostMicros/aiCostPrevMicros в сводке и стоимость в демо-данных.
Обработка входящих ловит теперь только сбой провайдера — лимиту неоткуда
сработать.

Валюта организации удалена целиком: поле принимало единственное значение
RUB, не читалось нигде и ни одна сумма в продукте в ней не считалась.
Убраны колонка с миграцией, валидация «только рубль», payload настроек и
платформенной выдачи, обязательное поле currency в API провижининга,
типы и транспорт на фронте.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 09:02:01 +03:00
AndreyandClaude Opus 5 b76871caf0 🐛 fix(chat): вернуть свёрнутый список диалогов, зелёный акцент по умолчанию
Кнопка «Показать список» жила только в шапке переписки. При пустом списке
или просто без выбранного диалога переписки нет — свёрнутый список
оказывался не вернуть ничем. Теперь кнопка есть и в пустой области, на том
же месте, что и в шапке. Сценарий закрыт e2e-тестом.

Цвет продукта по умолчанию — зелёный #0f9b8e: он же первый в палитре
акцентов профиля. Прежний синий остался вторым пресетом.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 08:20:14 +03:00
AndreyandClaude Opus 5 eff6d7ac0c 🔥 refactor(ai): убрать дневной лимит агента и выбор валюты организации
Дневной бюджет агента (limits.dailyCostUsd) снят вместе с полем. Проверка
работала, а учёт — нет: расход брался из ответа провайдера, а его шлёт
только OpenRouter; для Custom и локальной модели оставалась прайс-таблица
из двух моделей, и на любой другой стоимость записывалась нулём. То есть
на всех провайдерах, кроме OpenRouter, лимит не срабатывал никогда и давал
ложное чувство защиты. Общий лимит установки из переменной окружения
CHATBALLS_AI_GLOBAL_DAILY_COST_LIMIT_MICROS остаётся.

Выбор валюты убран из настроек организации и из формы её создания. Сервер
принимал только RUB, то есть в списке был один вариант, а само поле не
читается нигде: ни одна сумма в продукте не считается в валюте
организации. Колонка в базе остаётся, интерфейс её больше не спрашивает —
сервер проставляет значение сам.

Второй шаг онбординга больше не велит задавать дневной бюджет.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 07:54:14 +03:00
AndreyandClaude Opus 5 1cff9e2183 ✨ feat(onboarding): визард «Начало работы» вместо блока «Запуск»
Онбординг из восьми шагов: приветствие, визард с точным путём и превью
результата на каждом шаге, тур «Показать где» с подсветкой реального
элемента и финальный экран. Точки возврата — ссылка внизу субменю
«Настроек» и пилюля в углу рабочей области. Вёрстка по макету
design/baseline/Онбординг.

Признак «закрыл» и «прошёл» живёт на членстве человека в организации, а
не в localStorage: требование — показать визард всем, кто его ещё не
закрывал, включая тех, кто работает в установке давно. У каждого он свой,
поэтому один администратор не прячет визард команде. Прогресс шагов
считается по факту настройки, ручное «Далее» его не подменяет.

Заодно все селекты приложения переведены на общее меню: SelectMenu в
shared/ui-controls, поверх него FilterDropdown (фильтры списков) и
SelectField (поля форм). Нативных <select> не осталось, вместе с ними
ушли пять копий скина селекта. Изменения переплетены с онбордингом через
общие файлы (словари, e2e-сценарии), поэтому одним коммитом.

Устаревший design/design-system удалён: источник истины по интерфейсу —
макеты design/baseline/<фича>/*.dc.html.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 07:22:35 +03:00
AndreyandClaude Fable 5.1 21b8100598 ⚡ fix(updates): проверка канала релизов раз в 15 минут вместо шести часов
Обновление видно на установке в пределах четверти часа после публикации; кнопка «Проверить» по-прежнему делает запрос сразу. Интервал переопределяется CHATBALLS_UPDATE_CHECK_INTERVAL_SECONDS.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-13 04:29:18 +03:00
AndreyandClaude Fable 5.1 82805f7b1a ✨ feat(identity): организации из интерфейса — создание, выбор после входа, /join под ролью app
Кнопка «Добавить организацию» внизу переключателя у логотипа (A1) ведёт на
страницу /organizations/new: логотип, название, часовой пояс, валюта, язык.
Создавший становится владельцем и сразу переключается в новую организацию.
Право — у администратора установки и у владельца или администратора любой
организации; сервер проверяет то же (POST /api/v1/organizations/).

После входа учётная запись с несколькими организациями выбирает, с какой
начать: экран в рамке входа, строки «логотип · название · роль». Прямая
ссылка на организацию экран минует.

tenancy/0035: роль app вставляет организацию только в контексте заранее
выделенного id (как мастер первого запуска) вместо политики «только первая»;
security-barrier каталог invitation_directory — ссылка /join открывается без
контекста, и под ролью app приглашение раньше не находилось вовсе. Тем же
путём язык организации в профиле: членства читаются через каталог входа.

Тесты: создание организации по API, RLS под реальной ролью app (вставка
только в своём контексте, поиск приглашения по токену), e2e переключателя,
страницы создания и экрана выбора. UpdateState исключён из проверки покрытия
демо-набором — одна строка на установку.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-13 04:14:24 +03:00
AndreyandClaude Fable 5.1 5396dda8ad ✨ feat(updates): обновление установки из интерфейса
Установка раз в шесть часов и по кнопке проверяет страницу релизов
GitHub. Администратор установки видит баннер о новой версии и ставит
её одной кнопкой; карточка «Обновления» — в «Настройки → Платформа».

Установку выполняет отдельный сервис updater с Docker-сокетом:
backend-app общается с ним только файлами в томе chatballs-updates
(heartbeat, request.json, status.json). Updater принимает лишь релизы
своего репозитория с образами по digest и применяет compose.yaml в
одноразовом контейнере-помощнике, после чего кладёт файл в каталог
установки. Запрос установки попадает в аудит.

Релизный workflow собирает образ updater и пришпиливает его в
compose.yaml; версия бэкенда зашивается в образ (CHATBALLS_VERSION).
Миграции updates.0001 и tenancy.0034 (гранты на таблицу состояния).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-13 00:43:11 +03:00
AndreyandClaude Fable 5.1 c5a53ccec5 ✨ feat(identity): SVG в качестве логотипа организации
К PNG, JPEG и WebP добавлен SVG. Файл проверяется при загрузке и
отклоняется целиком, если содержит скрипты, обработчики событий,
foreignObject, внешние ссылки в href или url() внутри стилей, DOCTYPE и
сущности — переписывать чужую графику молча хуже, чем попросить другой
файл. При отдаче логотип получает Content-Security-Policy с sandbox и
nosniff как второй рубеж: адрес можно открыть и напрямую.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-13 00:03:54 +03:00
AndreyandClaude Fable 5.1 049217505a 🔇 fix(worker): пауза после сбоя опроса подключения и тишина в журнале
Подключение с ненастоящим токеном или недоступным сервером писало
предупреждение на каждом цикле опроса, каждые три секунды, на каждое
подключение. Теперь сбой транспорта — исключение PollFailed, а слой пауз
(transports.backoff) пропускает подключение с удвоением паузы от шести
секунд до четверти часа и пишет в журнал только смену состояния: первый
сбой, выход на максимальную паузу и восстановление. Курсор при сбое не
двигается. Демо-подключения из демо-набора помечаются demoSeed и не
опрашиваются вовсе: их токены ненастоящие.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-13 00:03:54 +03:00
AndreyandClaude Fable 5.1 5b49904d6a 🐛 fix(identity): адрес установки виден соседним процессам сразу, а не через 10 секунд
Кэш адреса установки живёт в каждом процессе gunicorn по 10 секунд. Мастер
первого запуска и смена адреса в «Настройках» сбрасывали его только в том
процессе, где выполнялись: соседний отвечал «Invalid host» на первый же
запрос после мастера, и приложение показывало «Ошибка загрузки» до
обновления страницы. Промах по хосту теперь перечитывает строку настроек,
но не чаще раза в секунду на процесс, чтобы поток запросов с чужим Host не
превращался в поток запросов к базе.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-12 23:18:43 +03:00
AndreyandClaude Fable 5.1 3e790af6e5 ✉️ feat(identity): приглашения в организацию — существующие учётные записи и владелец из провижининга
Учётная запись глобальная, а во вторую организацию было не попасть: форма
создания сотрудника отвечала «e-mail занят». Теперь занятый адрес из другой
организации получает приглашение с той же ролью, должностью, телефоном и
группами; членство появляется, когда человек принял его под своим входом.
Письмо отправляет воркер и выпускает токен в момент отправки — открытый
токен нигде не хранится. Повторное приглашение заменяет прежнее.

Приглашение владельца из платформенного провижининга доходит тем же
письмом. По ссылке /join предпросмотр говорит, есть ли учётная запись:
существующая идёт на вход и принимает приглашение, новая задаёт имя и пароль
(регистрация с теми же правилами, что в мастере) и активирует организацию.

Ожидающие приглашения отдаются в списке сотрудников блоком invitations с
действиями «отправить ещё раз» и «отозвать». Ответ на ошибки формы полями
переехал из мастера в auth/common — им пользуется и регистрация.
ADR-CHATBALLS-0047.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-12 14:37:25 +03:00
AndreyandClaude Fable 5.1 1390ccc426 🔒 fix(tenancy): гранты платформенной роли, backend-app только ролью app, организация видна в своём контексте
Провижининг организаций через платформенный API работал только в тестах:
роль chatballs_platform не имела прав на таблицы платформы, и уже проверка
токена падала с permission denied. Тесты ходят в базу владельцем кластера и
этого не видели — новый тест выполняет запрос под реальной runtime-ролью.

Публичный процесс backend-app держал platform-соединение ради каталогов
входа и мастера первого запуска. Теперь каталоги читаются ролью app
(SELECT на security-barrier вьюхи), а первую организацию мастер создаёт под
политикой «пока организаций нет» через SECURITY DEFINER-функцию; алиас
platform поднимается только у платформенной поверхности и воркера.

Роль app читала таблицу организаций целиком. Политика сужена до текущей
организации: входы без контекста находят id через каталог организаций и
читают строку уже внутри tenant_atomic (tenancy/lookup), воркер обходит
организации по каталогу, мастер выделяет id из последовательности заранее.
Должность владельца при провижининге берётся из словаря на языке
организации. ADR-CHATBALLS-0048.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-12 14:37:10 +03:00