Compare commits

...
35 Commits
Author SHA1 Message Date
Andrey 34afd9569f 🐛 fix(demo): покрыть новые модели веб-чата 2026-10-01 19:23:13 +03:00
Andrey 0a8514fe55 📝 docs(deploy): описать смену набора Compose override 2026-10-01 18:43:28 +03:00
Andrey 0f88c73abe 🔖 release: 1.16.0 2026-10-01 18:21:52 +03:00
Andrey 91fab586fa ✨ feat(deploy): сохранить override и постоянные dev-окружения
Сохранить порядок дополнительных Compose-файлов при обновлении и подключать их помощнику на чтение. Добавить проверки override, постоянных каталогов dev-данных и документацию проекта. Явно задать русский язык в тестах русских подписей.
2026-10-01 18:18:58 +03:00
Andrey 1a71abfe77 ✨ feat(webchat): добавить форму перед началом чата 2026-10-01 17:54:56 +03:00
Andrey 6c04ce30d8 ✨ feat(webchat): добавить настройки формы перед чатом 2026-10-01 17:51:52 +03:00
Andrey 8b9fae8a8d ✨ feat(conversations): показывать данные сайта в карточке контакта 2026-10-01 17:17:58 +03:00
Andrey 61b0dc2416 ✨ feat(webchat): добавить настройки оформления подключения 2026-10-01 13:11:16 +03:00
Andrey 52524287ae 📝 docs(webchat): добавить статью о классах виджета 2026-10-01 12:54:24 +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 223a962cb9 ✨ feat(webchat): добавить раздел данных с сайта 2026-09-30 23:18:58 +03:00
Andrey 5527a13230 ✨ feat(web-chat): стабильные классы и оформление окна виджета 2026-09-30 18:59:53 +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 dc6e38b689 ✨ feat(webchat): перенести настройки WEB-подключения на страницу 2026-09-30 09:33:34 +03:00
Andrey 36c95199a0 ♻️ refactor(portals): вынести меню разделов в shared 2026-09-29 13:09:19 +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 cc091dbd03 🔖 release: 1.15.3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 21:29:24 +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 Ermolaev d5c9d5c638 Merge pull request #4 from dartdavros/codex/release-1.15.2-cleanup
Подготовка релиза v1.15.2: очистка устаревших терминов
2026-09-24 18:03:41 +03:00
Andrey 7e0a12a2c9 ♻️ refactor: убрать устаревшие упоминания Hub и CustoAI 2026-09-24 18:02:27 +03:00
Andrey Ermolaev fc370fafe1 Merge pull request #3 from dartdavros/codex/help-center-navigation-related
Навигация и связанные статьи портала помощи
2026-09-24 14:09:33 +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 f57b1ee54a 🔖 release: 1.15.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 10:05:44 +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 cce87a4973 🔖 release: 1.14.3 2026-09-22 07:29:25 +03:00
Andrey 556246865e 🚨 fix(lint): исправить сортировку импортов 2026-09-22 07:29:03 +03:00
Andrey 0aff0c564b 🔖 release: 1.14.2 2026-09-22 07:13:46 +03:00
Andrey 0d307c7d06 🐛 fix(ai): сбрасывать circuit breaker после настройки провайдера
Повторы одного вызова теперь считаются одним логическим сбоем, поэтому предохранитель не открывается посреди второго сообщения. Изменение runtime-конфигурации и успешная проверка увеличивают ревизию интеграции: каждый event-worker заменяет открытый breaker при следующем запросе.

Удаление используемого провайдера возвращает 409 с понятной причиной и не пишет ложное событие об успешном удалении. Добавлены миграция и регрессионные тесты.
2026-09-22 07:13:20 +03:00
418 changed files with 14560 additions and 2434 deletions

No files matched your search

@@ -0,0 +1,36 @@
---
id: "0001"
title: Состояния и жизненный цикл диалога
status: accepted
date: 2026-09-28
---
## Контекст
Один комбинированный статус диалога даёт комбинаторный рост состояний: «открыт и отвечает AI», «открыт и ждёт оператора», «закрыт», «спам» и их сочетания. Повторное открытие давнего диалога смешивает разные обращения одного человека в одну ленту и искажает статистику.
## Решение
Состояние диалога разделяется на три независимые оси:
- **LifecycleState**: `OPEN`, `CLOSED`, `SPAM`;
- **ControlMode** — кто ведёт: `AI`, `HUMAN`, `PAUSED`;
- **ExpectedResponder** — от кого ждут реплики: `CUSTOMER`, `AI`, `OPERATOR`, `NOBODY`.
Правила:
- новый диалог создаётся в режиме `AI`;
- закрытие панели веб-чата, переход на другую страницу и закрытие браузера не меняют жизненный цикл;
- семь дней без активности переводят `OPEN` в `CLOSED`;
- новое сообщение после `CLOSED` создаёт новый `Conversation` того же контакта с `previous_conversation_id`;
- `SPAM` не переоткрывается и не создаёт новый диалог автоматически;
- для оперативной статистики активным считается открытый диалог с активностью за последние 15 минут.
Отклонены: один общий enum (комбинаторный рост); повторное открытие закрытого диалога (смешивает обращения, ломает статистику).
## Последствия
- Каждое обращение учитывается отдельно; оси не дублируют друг друга; история клиента связна через цепочку диалогов.
- Переходы валидируются доменным сервисом, а не свободной записью поля.
- Нужна фоновая задача автоматического закрытия.
- Транспорт обязан направить новое сообщение в новый диалог, а не дописать его в закрытый.
@@ -0,0 +1,45 @@
---
id: "0002"
title: AI-first обработка и ручной перехват
status: accepted
date: 2026-09-28
---
## Контекст
AI должен обрабатывать основную массу обращений, но сотрудник обязан мгновенно получить исключительный контроль над диалогом. Модель должна работать и с одним сотрудником, и с несколькими — без гонок и двойных ответов клиенту.
## Решение
Новый диалог ведёт агент, в подключение которого пришло обращение.
**AI может:** отвечать по инструкциям и прикреплённым знаниям, уточнять потребность, передавать клиенту ссылки на вложения знаний и статьи портала, запрашивать сотрудника и формировать резюме передачи. **AI не может:** обещать сведения, отсутствующие в знаниях, и совершать действия, оставленные человеку.
### Детерминированная передача человеку
Передача выполняется, если клиент попросил человека; в знаниях нет основания для ответа; запрос относится к операции только для человека; возник конфликт или жалоба; достигнут лимит агента; есть подозрение на злоупотребление. Самооценка модели «уверен / не уверен» основанием не является.
### Общая очередь и атомарный claim
- запросы человека попадают в общую очередь диалогов, ждущих ответа;
- сотрудник выполняет атомарный `claim`, забрать диалог может только один;
- в режиме `HUMAN` писать клиенту может назначенный сотрудник;
- владелец и администратор могут перехватить или переназначить диалог;
- сотрудник может вернуть диалог в очередь; блокировка учётной записи возвращает её открытые диалоги в очередь;
- закрытие браузера не снимает назначение;
- возврат к AI — только явным действием человека.
### Атомарный перехват у AI
1. блокировка диалога; 2. блокировка отправки новых AI-ответов; 3. отмена незавершённой генерации; 4. режим `HUMAN`; 5. назначение сотрудника; 6. системное событие; 7. резюме сотруднику.
Запрос онлайн-звонка — человеческое действие: из режима `AI` команда звонка сначала выполняет этот перехват и только после успеха создаёт `CallSession`; при конфликте claim приглашение клиенту не отправляется.
Отклонены: обязательная проверка человеком каждого AI-ответа (узкое место); автоматический возврат к AI по таймеру (неожиданный ответ робота); round-robin распределение (избыточно для масштаба).
## Последствия
- Двойной ответ клиенту исключён; очередь работает с любым числом сотрудников; передача проверяема.
- Нужны транзакционная блокировка и проверка режима перед каждой отправкой.
- Автоматического распределения диалогов по сотрудникам нет.
- Качество резюме зависит от конфигурации агента.
@@ -0,0 +1,31 @@
---
id: "0003"
title: Идентификация клиента и объединение контактов
status: accepted
date: 2026-09-28
---
## Контекст
Один человек может написать через Telegram, MAX, ВКонтакте, почту и веб-виджет; внешние идентификаторы разные. Автоматическое объединение по слабым признакам (имя, похожий адрес) смешивает данные разных людей, и ошибку потом нельзя обнаружить изнутри системы.
## Решение
- `Contact` — человек в организации;
- `ConnectionIdentity` — устойчивая идентичность внутри конкретного подключения: Telegram user ID, MAX user ID, ID пользователя ВКонтакте, адрес отправителя письма, для веб-виджета — идентификатор сессии и высокоэнтропийный credential (на сервере только hash, клиенту — короткоживущий токен).
Сессия веб-виджета хранится браузером в контексте конкретного виджета и восстанавливает анонимную историю, но **не является подтверждённой личностью** и сама по себе не объединяет контакт с другими подключениями. Очистка browser storage создаёт новую анонимную идентичность.
Каждое подключение принадлежит одному агенту; агент диалога определяется по подключению до начала разговора.
Автоматическое объединение идентичностей разных подключений запрещено, кроме случая, когда само подключение даёт подтверждённый номер телефона и совпадение однозначно. Объединение по имени запрещено.
Ручное объединение и разъединение доступны владельцу и администратору: сотрудник может предложить объединение; инициатор видит сравнение; операция требует причины; переносятся идентичности и диалоги; операция полностью аудируется и обратима.
Отклонены: автообъединение по email или введённому телефону (клиент может ввести чужой адрес); одно общее подключение на все транспорты (риск неправильного агента и знаний).
## Последствия
- Риск смешения персональных данных минимален; история сохраняется; ошибочное объединение откатывается.
- Один человек может временно существовать как несколько контактов.
- Требуется интерфейс предложения, объединения и разъединения.
@@ -0,0 +1,32 @@
---
id: "0004"
title: Согласие клиента и минимизация данных для LLM
status: accepted
date: 2026-09-28
---
## Контекст
Платформа обрабатывает сообщения и контактные данные клиентов. Внешняя модель не должна получать идентификаторы и персональные сведения, не нужные для ответа. Клиент публичного веб-виджета должен видеть, на что соглашается, до того как сообщение уйдёт в модель.
## Решение
### Согласие принадлежит точке входа
Текст согласия и его версия хранятся в конфигурации веб-виджета. Виджет показывает приветствие и согласие до начала разговора. До принятия: сообщение не передаётся в LLM, диалог с AI не начинается, сохраняется только минимальный технический контекст (показ согласия, сессия, защита от злоупотреблений). Версия меняется вместе с текстом. Для мессенджеров и почты отдельного экрана согласия нет — клиент сам инициирует переписку.
### Минимизация данных
Перед отправкой в модель текст проходит redaction; по умолчанию не передаются адреса e-mail, телефоны, длинные числовые идентификаторы (карты, платежи, документы), токены, секреты и внутренние идентификаторы. Runtime формирует отдельное очищенное представление — исходный текст в истории не меняется. Персональные данные попадают в модель только через явно разрешённый инструмент.
### Удаление и обезличивание
Владелец может обезличить коммуникационные данные: связь с контактом заменяется техническим идентификатором, история остаётся как факт коммуникации. Сроки хранения задаёт организация.
Отклонены: передача модели полной карточки клиента; хранение только флага «согласие получено» (не доказать, какой текст был показан).
## Последствия
- Объём персональных данных у провайдера минимален; текст согласия версионирован; удаление данных клиента не разрушает историю.
- Нужен отдельный слой redaction и payload для модели.
- Redaction по шаблонам не ловит произвольные упоминания ПДн в свободном тексте — осознанный компромисс.
@@ -0,0 +1,24 @@
---
id: "0005"
title: Структура репозитория и границы развёртывания
status: accepted
date: 2026-09-28
---
## Контекст
Код, дизайн и служебные материалы должны иметь один канонический источник внутри репозитория. Публикуемый open-source репозиторий не должен содержать второго дерева с копиями макетов, второго production-манифеста или приватных материалов владельца.
## Решение
- Каноническая структура: `apps/backend` (Django: API, ASGI, домены, воркеры), `apps/internal-ui` (интерфейс организации и публичные порталы, выбор по HTTP Host), `apps/web-chat` (виджет, клиентская страница звонка, лоадер), `packages/ui`, `packages/contracts`, `packages/shared`, `design/baseline`, `deploy`, `scripts`, `tests`, `compose.yaml`, `compose.dev.yaml`, `Caddyfile`. Новые приложения и пакеты вне структуры — только отдельным решением; параллельные каталоги дизайна и дублирование макетов запрещены.
- `apps/backend` — единственный источник доменной логики; фоновые обработчики — процессы из того же кода.
- Публичный адрес — свойство установки, а не константа репозитория; домены в код, конфигурацию и документацию не зашиваются. Публичные пути: `/` (интерфейс), `/api/`, `/ws/`, `/chat/`, `/calls/`, `/chat-widget.js`.
- Один `compose.yaml` для production и коробки; разработка добавляет только `compose.dev.yaml`. Секреты и реквизиты внешних сервисов в репозитории не хранятся.
- Проектная документация владельца приватна и в публикацию не входит; публично поставляется `README.md` репозитория.
## Последствия
- Код, дизайн и документация живут в одном контуре; дублирование макетов и манифестов исключено; локальная разработка не зависит от серверного развёртывания.
- Любое новое приложение требует отдельного решения.
- Публичная документация пишется отдельно и не появляется сама из проектных документов.
@@ -0,0 +1,27 @@
---
id: "0006"
title: Каналы уведомлений сотрудников
status: accepted
date: 2026-09-28
---
## Контекст
Контакт-центр — рабочая система: если клиент ждёт человека, а сотрудник закрыл вкладку, событие не должно потеряться. Клиентские боты организации не должны использоваться как служебный канал для сотрудников — разные аудитории, токены и права.
## Решение
**Способы доставки:** центр уведомлений в интерфейсе; звуковой сигнал в чате; browser notifications; письмо через почту установки; служебный Telegram- или MAX-бот.
**Разделение ботов.** Служебный бот — отдельная интеграция с назначением «уведомления»: не привязывается к агенту и не принимает клиентские диалоги; токены, аудит и права клиентского и служебного контуров разделены. Сотрудник привязывает себя сам: в профиле получает одноразовый код с коротким TTL и открывает бота по deep-link; привязка хранит внешний ID чата и набор типов уведомлений для мессенджера.
**Типы событий** (базовые): `DIALOG_WAITING` — диалог ждёт человека; `DIALOG_NEW_MESSAGE` — новое сообщение в диалоге под управлением человека; `INTEGRATION_ERROR` — подключение или провайдер недоступны. Уведомление адресуется всем, владельцу, работающим с диалогами сотрудникам или конкретному пользователю и содержит диплинк на объект. Новый тип — запись в реестре типов, а не новый механизм доставки.
**Настройки.** Сотрудник настраивает доступные способы и типы в пределах ограничений роли.
## Последствия
- Рабочие события не теряются при закрытом браузере; Telegram и MAX равноправны; служебное не смешивается с клиентским; новые способы доставки добавляются без изменения доменных событий.
- Служебный бот требует отдельной регистрации; каждая доставка идемпотентна; недоступность одного способа не блокирует остальные.
- Пока почта установки не настроена, приглашения и сброс пароля уходят в лог.
- Поведение прочтения уведомлений — SPEC-0017.
@@ -0,0 +1,26 @@
---
id: "0007"
title: "Технология retrieval знаний: pgvector и FTS PostgreSQL"
status: accepted
date: 2026-09-28
---
## Контекст
Агенту нужен поиск по прикреплённым материалам, находящий и точные формулировки, и близкие по смыслу. Требования: работать в уже используемой инфраструктуре, не вводить отдельный сервис хранения, поддерживать лексический и семантический поиск и фиксировать, какие фрагменты попали в ответ.
## Решение
Retrieval реализуется в PostgreSQL: семантический поиск — `pgvector` (образ `pgvector/pgvector:pg16`, расширение включается миграцией); полнотекстовый — встроенный FTS.
- Содержимое источника разбивается на фрагменты (`KnowledgeFragment`): текст и эмбеддинг. Размерность колонки не фиксируется: эмбеддинги считает адаптер провайдера (демо — детерминированный малой размерности, иначе провайдер организации).
- Источников фрагмента два — знание библиотеки и статья портала (ADR-0017); «ровно один источник» закреплён CHECK-ограничением.
- Retrieval объединяет лексических и семантических кандидатов среди материалов, доступных агенту, ранжирует их; идентификаторы использованных фрагментов сохраняются в журнале вызова модели.
- На текущем объёме допускается последовательное сканирование без ANN-индекса. При росте объёма добавляется HNSW/IVFFlat с фиксированной размерностью выбранной embedding-модели — отдельной миграцией, без изменения интерфейса retrieval.
Отклонены: внешнее векторное хранилище (лишний сервис); только FTS (нет семантики); фиксированная размерность сразу (ломает совместимость провайдеров).
## Последствия
- Один компонент инфраструктуры для лексики и семантики; интерфейс retrieval скрывает реализацию.
- Без ANN-индекса поиск линейный; ANN потребует фиксации размерности и переиндексации при смене модели.
@@ -0,0 +1,36 @@
---
id: "0008"
title: "Интеграции: провайдеры и подключения"
status: accepted
date: 2026-09-28
---
## Контекст
Подключение к внешним системам было неявным: ключ провайдера задавался переменной окружения, транспорт предполагался как «один бот на всё». Владелец должен заводить и проверять подключения в интерфейсе, экземпляров одного типа может быть несколько, секреты должны храниться зашифрованными, а не в окружении.
## Решение
**Интеграция — единица внешнего подключения**, заводится и проверяется в «Настройки → Интеграции». Два рода:
- **LLM-провайдер** — OpenRouter, произвольный OpenAI-совместимый endpoint (ADR-0014), демо-провайдер без ключей;
- **Подключение (транспорт)** — Telegram-бот, MAX-бот, сообщество ВКонтакте, веб-виджет, почтовый ящик (ADR-0015). Экземпляров много; каждое подключение принадлежит **одному** агенту.
Платформенных credentials и managed-режима нет (ADR-0020).
**Маршрутизация провайдера** — только через явно выбранную интеграцию агента; «первая попавшаяся интеграция организации» и глобальная подмена выбора владельца запрещены.
**Каталог моделей** адаптер отдаёт через backend; allowlist не ведётся. Модель выбирается поисковым списком; backend проверяет её наличие в каталоге интеграции; исчезнувшая модель не подменяется автоматически, конфигурация помечается как требующая выбора. Исключение — Custom: каталога нет, модель вводится текстом. Браузер никогда не получает credentials.
**Хранение и проверка:** секреты шифруются в БД; у интеграции есть статус, время и текст последней ошибки; «Проверить» выполняет реальную проверку (провайдер — health и capability, бот — `getMe`, ВКонтакте — ключ и настройки Long Poll сообщества, почта — IMAP и SMTP); `is_active` отделён от статуса проверки. Ошибка проверки объясняется словами, а не кодом; полный ответ провайдера — только в журнал. Исходящие запросы представляются User-Agent `Chatballs/<версия>`.
**Приём и отправка:** входящие — идемпотентно через inbox (уникальность по источнику и внешнему id), нормализация в доменное сообщение; исходящие — через outbox с ретраями; поллинг в воркере или вебхук. После сбоя опроса подключение пропускается с растущей паузой, курсор не двигается, в журнал — только первый сбой, выход на максимальную паузу и восстановление.
**Границы:** интеграция — чистый транспорт или доступ к провайдеру; не хранит знания, инструкции, диалоги и конфигурацию агента. Что разрешено в точке входа (голосовые, звонки) — свойство интеграции.
Отклонены: ключ провайдера только в окружении; один бот на всё.
## Последствия
- Провайдеры и боты управляются и проверяются в интерфейсе; модель выбирается из актуального каталога; несколько ботов одного типа из коробки; секреты зашифрованы; приём и отправка идемпотентны.
- Нужен жизненный цикл и ротация секретов; для вебхуков нужен публичный адрес, по умолчанию — поллинг; ошибки внешних API требуют внимания владельца.
@@ -0,0 +1,26 @@
---
id: "0009"
title: Модель агента и знаний без версий и релизов
status: accepted
date: 2026-09-28
---
## Контекст
Первая итерация строила AI-конфигурацию на трёх уровнях: библиотека версионируемых документов, агент и неизменяемый релиз. Чтобы поправить одну фразу, владелец проходил четыре шага (черновик версии → публикация версии → сборка релиза → публикация релиза), а в интерфейсе жили три источника истины.
## Решение
**Знание — плоская сущность с вложениями.** `Knowledge`: заголовок, краткое описание, текст (Markdown), признак «включено»; версий нет. Вложения: оригинальное имя сохраняется и уникально в рамках знания (текст ссылается по имени); из md, txt, pdf, docx извлекается текст для индексации; у вложения публичная ссылка с непредсказуемым UUID — агент передаёт её клиенту в любом подключении. Защита ссылки — только непредсказуемость UUID (осознанный компромисс). Фрагменты и эмбеддинги перестраиваются при каждом изменении содержимого или вложений.
**Агент — одна сущность без релизов:** модель и параметры генерации; инструкции из трёх частей — Персонализация, Тон общения, Инструкции; прикреплённые знания и статьи портала; дневной лимит стоимости; статус. Системный промпт: Персонализация → Тон → Инструкции → каталог прикреплённых материалов (заголовок, описание, ссылки вложений); содержимое подтягивается retrieval'ом.
Изменения агента и знаний применяются в runtime **сразу**.
Отклонены: сохранить релизы, упростив документы (главная сложность — многошаговая публикация); версионируемые вложения (файл заменяется загрузкой с тем же именем).
## Последствия
- Правка в один шаг, одна точка настройки, вложения можно передать клиенту.
- Нет атомарной публикации и отката; нет истории версий; расследование ответа опирается на журнал использованных фрагментов.
- Публичные ссылки вложений доступны любому, у кого есть URL.
@@ -0,0 +1,29 @@
---
id: "0010"
title: P2P-онлайн-звонки в диалогах
status: accepted
date: 2026-09-28
---
## Контекст
Часть обращений быстрее закрыть голосом. Переход из переписки в звонок не должен создавать внешнюю конференцию, терять связь с диалогом или требовать от клиента установки чего-либо. Интеграции с готовыми платформами видеосвязи не принимаются. Запись звонков сознательно отложена.
## Решение
- **Звонок — часть диалога.** `CallSession` связан с существующим диалогом; в ленту попадают только системные события: запрос, принятие, отклонение, начало, завершение, пропуск, ошибка.
- **Участники:** один сотрудник с доступом к диалогу и один клиент; один незавершённый звонок на диалог; аудио и видео, разговор продолжается при выключенной камере. AI не участвует. Групповые звонки, второй сотрудник, screen sharing не входят.
- **Медиамаршрут:** WebRTC напрямую; при невозможности — собственный Coturn как relay, который ничего не записывает.
- **Собственный signaling:** backend создаёт сессию и приглашение, проверяет доступ, ведёт состояния, передаёт SDP и ICE между разрешёнными участниками, таймауты, аудит. SDP, ICE и медиа не сохраняются ни в БД, ни в логах.
- **Доставка приглашения:** в веб-чате — realtime-событием в сессию; в Telegram, MAX и ВКонтакте — кнопкой на защищённую страницу `/calls/<токен>`. Токен высокоэнтропийный, привязан к приглашению, идентичности и организации, ограничен по сроку, не даёт доступа к истории и не работает после завершения.
- **Состояния:** `REQUESTED · RINGING · ACCEPTED · CONNECTING · ACTIVE · DECLINED · CANCELLED · MISSED · ENDED · FAILED · EXPIRED`.
- **Развёртывание:** Coturn — отдельный Docker-сервис, который **поднимается вместе со стеком** (с релиза 1.8.0; ранее включался профилем `calls`): host network, порт 3478 TCP/UDP, relay-диапазон 49160–49999/udp, TURN-over-TLS по умолчанию выключен. Порты публикуются напрямую, HTTP-шлюз TURN не проксирует. Адреса relay и STUN вычисляются от адреса установки, вписанные вручную побеждают. Backend выдаёт только краткоживущие credentials.
- **Запись отложена:** ни аудио, ни видео не записываются. Будущая запись — только аудио, отдельным сервисом, отдельным решением о хранении, доступе и сроках.
Отклонены: готовый Video SDK (LiveKit, Jitsi, Zoom); серверный медиасервер/SFU; запись через браузер сотрудника; TURN как механизм записи.
## Последствия
- Звонок остаётся частью диалога; нет зависимости от внешней платформы; при прямом соединении медиа не проходит через backend.
- Качество зависит от сети и браузеров; при relay сервер несёт медиатрафик; на файрволе нужно открыть `3478/udp`, `3478/tcp` и `49160–49999/udp`.
- Нужно отдельное тестирование мобильных браузеров, NAT и восстановления соединения.
@@ -0,0 +1,31 @@
---
id: "0011"
title: Единый контур развёртывания и установка без конфигурации
status: accepted
date: 2026-09-28
---
## Контекст
Пока локальная разработка, production владельца и установка у пользователя описывались разными манифестами и командами, они расходились: порядок миграций, health checks, шлюз и bootstrap жили своей жизнью. Для open-source поставки пользователь должен получить ровно тот путь установки, который владелец ежедневно проверяет.
## Решение
- **Один production-контур** для первичной установки, обновления, production владельца, staging и восстановления.
- **Один канонический `compose.yaml`**: топология, зависимости, health checks, тома, образы. `compose.dev.yaml` может подменять образ сборкой, команды — dev-серверами и монтировать исходники, но не описывает вторую топологию.
- **Установка без конфигурации.** Файла `.env` нет. Секреты инстанса (ключ подписи, ключ шифрования полей, пароли ролей БД, секрет TURN) генерирует одноразовый сервис `secrets` в тома `chatballs-secrets`, `chatballs-secrets-platform`, `chatballs-secrets-schema` (ADR-0026); скрипт идемпотентен и не меняет пароли работающей базы. Адрес, почта, хранилище, TURN, интеграции и AI-провайдер — в интерфейсе. Переменная окружения выше файла секрета — для оркестраторов.
- **`release.env`** — только версия и неизменяемые ссылки на образы по digest; без него стек собирается из исходников. `latest` источником версии не является.
- **One-shot `init`** — только миграции; не создаёт организацию, владельца, демо-данные; безопасен при повторе.
- **Мастер первого запуска** вместо bootstrap-переменных: пока организаций нет, установка показывает мастер; после создания владельца он закрывается навсегда. Адрес, на котором прошли мастер (включая нестандартный порт), запоминается как публичный адрес установки.
- **Deployment CLI** `./chatballs` без Python и Node на хосте: реализованы `doctor`, `deploy`, `status`, `logs`; `install`, `update`, `backup`, `restore`, `rollback` — следующий этап. CI вызывает тот же entrypoint.
- **Шлюз внутри Docker (Caddy)**: без зашитых хостов; http отвечает на любом адресе и порту; сертификат on-demand для хостов, одобренных backend; принудительного редиректа на https нет. За прокси панели шлюз принимает `X-Forwarded-Proto`.
- **Runtime-конфигурация frontend**: домен не компилируется в образ, используются same-origin относительные URL.
- **Разделение bootstrap-данных**: миграции и справочники — при каждом deploy; организация и владелец — один раз через мастер; демо-данные — опционально, со своим жизненным циклом, не профиль Compose и не переменная.
Отклонены: отдельный box-installer поверх CI-деплоя (два источника истины); конфигурация экземпляра в `.env`; демо-данные как профиль или переменная.
## Последствия
- Установка пользователя и production владельца идут одним кодовым путём; образ frontend не зависит от домена; не нужны host nginx и переменные.
- Caddy — часть поддерживаемой инфраструктуры; откат ограничен совместимостью миграций.
- Часть команд CLI не реализована — их роль выполняют ручные операции и `deploy`.
@@ -0,0 +1,29 @@
---
id: "0012"
title: Мультитенантность, membership и изоляция данных
status: accepted
date: 2026-09-28
---
## Контекст
Одна установка должна обслуживать несколько изолированных организаций. Прежняя модель связывала пользователя ровно с одной организацией и хранила в одной записи роль, должность, блокировку и MFA; блокировка деактивировала глобального пользователя. Один пропущенный фильтр по `organization_id` открывает чужие данные — нужна защита в несколько слоёв.
## Решение
1. **Organization — граница владения**, авторизации, аудита и удаления. Неизменяемый `public_id` (UUID) для API и маршрутов; внутренний ключ — для связей и RLS. Каждая сущность — platform-owned, tenant-owned или публичные данные точки входа; неопределённая принадлежность запрещена.
2. **Identity и membership.** `HumanUser` — глобальная учётная запись; `OrganizationMembership` — роль, должность, статус, блокировка; `unique(user, organization)`. Блокировка membership закрывает доступ только к своей организации. Владелец и администратор не меняют чужой глобальный пароль, TOTP и сессии. В организации ровно один активный владелец; передача владения — атомарная аудируемая операция. TOTP принадлежит пользователю; требование MFA — политика организации. Приглашение — отдельная сущность (ADR-0025).
3. **Tenant context** передаётся явно: `/api/v1/organizations/{organization_public_id}/...`. Порядок: аутентификация → разрешение организации → транзакция и контекст → активная membership → роль и видимость → данные. Последняя выбранная организация — предпочтение интерфейса, не авторизация (разные организации в разных вкладках).
4. **Изоляция в приложении:** каждый endpoint, selector, сервис и репозиторий принимает проверенный контекст; объект загружается вместе с организацией; массовые операции, management-команды и импорт соблюдают те же правила.
5. **Row-Level Security:** `ENABLE`/`FORCE`, `USING` и `WITH CHECK`, runtime-роль не superuser, без `BYPASSRLS`, не владеет таблицами; роль миграций отдельна. Контекст — `SET LOCAL`; session-level `SET` запрещён. Отсутствующий контекст строк не открывает. Platform-операции — своя ограниченная граница и аудит.
6. **Ключи и связи:** корневые tenant-сущности и таблицы с независимым доступом, воркерами, своей RLS-политикой или экспортом имеют прямой `organization_id`. Cross-organization связи запрещены на всех слоях, включая constraint triggers.
7. **Точки входа:** HTTP — организация в маршруте, единообразный отказ; WebSocket — проверка membership и принадлежности до подписки; фоновые задачи несут организацию и актора, воркер перепроверяет; публичные точки входа выводят организацию server-side; ключи хранилища начинаются с идентификатора организации.
8. **Platform administration:** администратор установки — глобальный признак (`HumanUser.is_instance_admin`, ADR-0024), не membership во всех организациях; Django superuser — только break-glass. Доступ к данным организации — через явную support-сессию с организацией, причиной, сроком, полномочиями и аудитом.
9. **Аудит субъекта** различает глобального пользователя, membership, platform-оператора, AI-агента и машинный credential; попытки cross-tenant доступа сохраняются отдельно.
Отклонены: одна организация на пользователя; активная организация только в сессии; только фильтры в приложении; только RLS; схема или база на организацию; отключение RLS в одиночной установке; platform-администратор как владелец каждой организации.
## Последствия
- Один пользователь безопасно работает в нескольких организациях; ошибка в коде перестаёт быть единственным барьером.
- RLS требует транзакционного контекста во всех контурах; аналитике по установке нужна отдельная граница; пропущенный tenant-ключ обнаруживается только тестом изоляции.
@@ -0,0 +1,26 @@
---
id: "0013"
title: Поверхности приложения, адреса и административный доступ
status: accepted
date: 2026-09-28
---
## Контекст
В одной установке живут рабочее приложение организаций, control plane установки и публичные порталы. Объединение их на одном host и в одной сборке повышает риск раскрытия платформенных функций и смешения cookie, маршрутов и авторизации. Django admin — break-glass инструмент и не должен публиковаться в интернете.
## Решение
1. **Адрес — свойство установки.** Зашитых доменов нет; свежая установка открывается по IP или выбранному домену; адрес запоминается на хосте, где прошли мастер. Порталы — на собственных хостах (ADR-0016). Платформенная поверхность включается отдельным доменом; пока он не задан, её матчер ни с чем не совпадает.
2. **Разделение поверхностей:** приложение организации (рабочее пространство, tenant API, публичные точки входа); platform control plane (отдельный домен); Django admin (только loopback). Публичная поверхность определяется по HTTP Host **до** разбора маршрута; неизвестный путь публичного хоста — публичный `404`, а не форма входа.
3. **Раздельные точки входа backend:** `backend-app`, `backend-platform`, `backend-admin` — один образ, свои URLConf и минимальные наборы маршрутов. Учётные данные БД разделены по процессам (ADR-0026).
4. **Django admin только локально** — loopback, доступ через SSH-туннель; публичный reverse proxy для admin запрещён без нового решения. Ежедневное управление — через интерфейс.
5. **Сессии и cookie:** раздельные host-only сессии приложения и платформы; общая cookie на домен запрещена; без `Domain`, `HttpOnly`, подходящий `SameSite`. По https — префикс `__Host-`, `Secure` и HSTS; по http — обычные имена.
6. **Границы API:** tenant API — UUID организации, membership, роль, видимость, RLS; платформенное API — отдельное пространство имён и аудит, к данным организации — только через support-сессию. Настройки установки — `/api/v1/instance/` (ADR-0024). Видимость маршрута во frontend авторизацией не является.
Отклонены: одна общая поверхность; admin по публичному адресу; защита admin только правилом шлюза; общая cookie на домен; отдельный домен для API; домены, зашитые в образ.
## Последствия
- Управление установкой отделено от рабочего приложения; admin вне поверхности атаки; установка переезжает с IP на домен без пересборки.
- Три процесса backend; раздельные cookie, hosts и CSRF origins.
@@ -0,0 +1,27 @@
---
id: "0014"
title: Произвольный OpenAI-совместимый провайдер
status: accepted
date: 2026-09-28
---
## Контекст
Правило «адаптер под каждого провайдера и запрет свободного ввода модели» (ADR-0008) означало закрытый список провайдеров: свой inference-endpoint или менее известного провайдера нельзя было подключить без правки кода. Для self-hosted продукта выбор провайдера — дело пользователя.
## Решение
Организация подключает произвольный OpenAI-совместимый endpoint тремя значениями: **endpoint** (базовый URL), **API key** (шифруется в контуре организации), **модель** (свободный текст).
Исключения из общих правил интеграций для этого режима: модель вводится текстом; любой OpenAI-совместимый endpoint принимается без адаптера; каталог не требуется. Остальное сохраняется: маршрутизация через интеграцию агента, запрет подмены выбора, шифрование, статус проверки, границы организации, серверные credentials.
Контракт: OpenAI Chat Completions — `POST /chat/completions`, `Authorization: Bearer <key>`, ответ с `choices[0].message.content` и `usage`; переиспользуется HTTP-слой OpenRouter. Endpoint другой формы требует отдельного адаптера.
Поле модели читается в runtime; валидации против провайдера нет — ответственность на владельце.
Отклонены: OpenRouter, поглощающий generic-режим (теряется каталог); сохранить запрет свободного ввода; требовать адаптер для любого провайдера.
## Последствия
- Любой OpenAI-совместимый провайдер без правки кода; OpenRouter остаётся готовой интеграцией с каталогом.
- Модель не проверяется до первого вызова; ошибка идентификатора проявляется как ошибка провайдера в статусе интеграции.
@@ -0,0 +1,26 @@
---
id: "0015"
title: Email-подключение (IMAP/SMTP)
status: accepted
date: 2026-09-28
---
## Контекст
Часть клиентов пишет на почту поддержки, и эта переписка жила вне системы: без агента, передачи человеку и истории по контакту. Почта самой установки (приглашения, сброс пароля) — транзакционная и диалоговым транспортом не является.
## Решение
- Почтовый ящик — подключение рода «мессенджер» наравне с ботами: ящиков несколько, каждый привязан к одному агенту, заводится и проверяется в «Интеграциях».
- Секрет — пароль ящика (обычно пароль приложения), один для IMAP и SMTP. Несекретно: адрес (он же логин), хосты, порты, флаги SSL. OAuth2 в первой итерации не поддерживается.
- **Приём:** поллинг IMAP в воркере; курсор `uidvalidity:last_uid`, смена `UIDVALIDITY` сбрасывает курсор; при первом подключении курсор ставится на текущий максимум — история ящика не импортируется; идемпотентность по `Message-ID`; идентичность — адрес отправителя в нижнем регистре; `text/plain` всегда, для HTML — дополнительно санитизированный HTML со структурным allowlist; собственные письма ящика пропускаются; вложения в первой итерации не принимаются, в текст добавляется пометка.
- **Отправка:** через outbox по SMTP, `Re: <тема>`, `In-Reply-To` и `References` на последнее входящее; диалог хранит транспортную мету. Исходящие диалоги, начатые сотрудником, не создаются.
- **Проверка** проходит только при успехе обеих сторон: IMAP (соединение, вход, папка) и SMTP (соединение, `EHLO`, вход).
- **Границы:** не заменяет почту установки, не используется для писем аутентификации, не канал уведомлений сотрудников, не для рассылок.
Отклонены: OAuth2 в первой итерации; IMAP IDLE вместо поллинга; отдельный род интеграции «почта».
## Последствия
- Почта становится полноценными диалогами; переиспользуются поллер, inbox/outbox, шифрование и проверка.
- Задержка в пределах интервала воркера; вложения входящих теряются (с пометкой); ящик, который параллельно читают люди, может давать двойные ответы — рекомендуется выделенный ящик.
@@ -0,0 +1,26 @@
---
id: "0016"
title: Публичные порталы поддержки
status: accepted
date: 2026-09-28
---
## Контекст
Организации нужен публичный Help Center: база статей, которую читает клиент, с возможностью тут же написать в поддержку. Порталов может понадобиться несколько — для разных брендов или направлений.
## Решение
- **Портал — объект организации.** Список, создание, настройка, контент, публикация и архив — в одном разделе; отдельных прав портала нет, действуют права роли.
- **Адрес — отдельный публичный хост**: `https://<portal-key>.<базовый help-домен>/` и `/articles/<slug>`. Можно подключить свой домен: он нормализуется и проверяется (не совпадает с доменом приложения и адресом портала); подтверждения владения нет, только техническая проверка, что домен ведёт на сервер. До активации работает адрес по умолчанию. Поверхность определяется по Host до разбора маршрута; неизвестный путь — публичный `404`. Шлюз выпускает сертификаты для доменов порталов по одобрению backend.
- **Без ограничений:** лимитов и тарифных условий нет; черновик не блокирует создание следующего портала.
- **Контент:** категории, статьи и неизменяемые редакции; публикация выбирает одну редакцию. Портал и библиотека знаний не объединяются; агент может ссылаться на статью (ADR-0017).
- **Общий визуальный язык:** управление материалами портала — доменная адаптация библиотеки знаний, а не второй интерфейс. Компоненты дерева, таблицы, панели действий, категорий, редактора и подтверждений лежат в общем модуле и импортируются обеими функциями; копирование разметки под другим префиксом переиспользованием не считается.
- **Веб-чат портала** — конкретный опубликованный виджет (ADR-0018), разрешающий origin портала и ведущий в активного агента. Только штатный лоадер; свой launcher, iframe или вторая реализация чата запрещены.
## Последствия
- Портал не появляется в глобальной навигации установки; у каждого — свой адрес и тема (ADR-0022).
- Публичный Help Center не загружает внутренний интерфейс как fallback.
- Архивный портал — только чтение и восстановление.
- Изменение адресного контракта требует отдельного решения.
@@ -0,0 +1,25 @@
---
id: "0017"
title: Статьи портала как источник знаний агента
status: accepted
date: 2026-09-28
---
## Контекст
У агента поддержки и публичного Help Center один предмет. Раньше статью писали в портал, а для агента повторяли знанием — копия расходится с оригиналом при первой правке. Фрагмент индекса обязательно принадлежал знанию, и retrieval физически не видел статью.
## Решение
1. **Агент ссылается на статью, а не на копию.** Вводится связь агента со статьями; зеркалирование отвергнуто (второй источник правды, синхронизация, нередактируемые записи). Правка статьи сразу меняет ответы агента.
2. **Один индекс фрагментов на два источника**: знание либо статья, «ровно один источник» — CHECK-ограничение. Retrieval и учёт фрагментов — один запрос и одно пространство идентификаторов.
3. **Индексируется только опубликованная редакция**: фрагменты строятся при публикации и снимаются при архивации; черновик не индексируется никогда. Связь агента со статьёй при архивации сохраняется.
4. **Доступность — в границах организации**; в runtime проверяется, что статья опубликована, а портал не архивирован. Массовая операция над смешанной выборкой не падает: недоступные элементы пропускаются и возвращаются списком.
5. **Состав знаний агента меняется только из библиотек** — массовыми действиями в разделах знаний и порталов. Карточка агента показывает нередактируемый список. Мастер создания агента знания не выбирает.
## Последствия
- Нет двойного ввода; статья — единственный источник правды; агент может дать клиенту ссылку на статью Help Center.
- Код, читающий источник фрагмента, обязан учитывать оба варианта.
- Архивация и снятие публикации немедленно убирают статью из ответов.
- Карточка агента перестаёт быть местом изменения состава знаний.
@@ -0,0 +1,28 @@
---
id: "0018"
title: Веб-виджет как самостоятельная точка входа
status: accepted
date: 2026-09-28
---
## Контекст
Веб-чат используется в нескольких независимых местах: на портале, на сайте организации, на разных доменах и страницах. Раньше визуальный экземпляр виджета, веб-подключение и агент отождествлялись, а лоадер адресовал чат кодом агента. Нельзя было выбрать один из нескольких виджетов агента, держать разные списки доменов и оформление, достоверно записать источник обращения.
## Решение
1. **`WebChatWidget` — самостоятельный объект**: `integration` (1:1 с WEB-подключением), `code` (неизменяем в организации), `public_key` (неизменяем, глобально уникален, не секрет), `name`, `status` (`DRAFT | PUBLISHED | DISABLED`), `allowed_origins`, `presentation_config`, `consent_config`, `anti_abuse_config`. `public_key` можно передавать в HTML; он не credential и не заменяет проверку origin и сессионный credential.
2. **Разные ответственности:** агент — обработка (инструкции, знания, модель, группа); виджет — только вход (место установки, адресация, origins, оформление, согласие, anti-abuse). `Агент 1 — N WEB-подключение 1 — 1 WebChatWidget`. Выбор агента внутри виджета не поддерживается.
3. **Вход только анонимный**; «режима» у виджета нет (ADR-0023). Агент виджета должен разрешать анонимные сессии.
4. **Публичная маршрутизация — по ключу виджета**; клиент не выбирает и не подменяет агента.
5. **Анонимная сессия привязана к виджету**: credential одного виджета не принимается другим; очистка хранилища или другое устройство — новая анонимная идентичность без автообъединения.
6. **Источник обращения сохраняется**: диалог фиксирует виджет, из которого начался.
7. **Конфигурация и origin принадлежат виджету**; origin — обязательная дополнительная проверка, а не единственная.
Отклонены: один код агента = один виджет; несколько веб-подключений с поиском по коду агента; один виджет с выбором агента.
## Последствия
- Несколько виджетов одного агента — штатный сценарий; портал и сайт используют один контракт входа.
- Виджет с историей не удаляется, а отключается; преемственность анонимной истории между виджетами намеренно отсутствует.
- Одновременный показ нескольких launcher'ов на странице требует отдельного дизайн-решения.
@@ -0,0 +1,34 @@
---
id: "0019"
title: "Контакт-центр: удаление продаж и отделов, объединение агента и канала"
status: accepted
date: 2026-09-28
---
## Контекст
Chatballs развивался как операционная CRM: отделы продаж и поддержки, учёт внешних продаж через Product Sales API, атрибуция, коммерческий каталог, метрики выручки, capability/scope-авторизация с отделами и department-видимостью знаний.
Практика показала два расхождения. **Продажи не являются ценностью продукта**: организации нужен контакт-центр, а не учёт чужих коммерческих фактов. **Тройка «подключение → канал → агент» и отделы непосильны для администратора**: новая организация должна освоиться за минуты, а не изучать внутреннюю архитектуру.
## Решение
Это основание текущей продуктовой модели.
1. **Chatballs — платформа клиентских коммуникаций (контакт-центр).** Продажи, сделки, воронки, заказы, счета, атрибуция и выручка не входят ни в каком виде.
2. **Домен продаж удалён полностью** без сохранения данных: приложения `sales` и `orders`, коммерческий слой каталога (`Offer`, `Price`, `MarketplacePublication`), метрики выручки и конверсии, налоговый профиль организации, разделы «Продажи» и «Продукты», capabilities `sales.*` и `products.*`.
3. **Отделы (`Department`) упразднены.** Структура групп впоследствии заменена настраиваемыми группами (ADR-0021).
4. **Агент и канал — одна сущность для пользователя.** Агент содержит имя, инструкции, модель и провайдера, знания, подключения и признак активности. Подключение — транспорт (ADR-0008), привязано к агенту и управляется только из карточки агента; разделов «Каналы» и «Подключения» в интерфейсе нет. В схеме БД сохраняется пара `Channel` — `AIAgent` 1:1.
5. **Модель доступа** — роли вместо capability registry, access profiles и department scopes; deny-by-default в backend сохраняется. Действующие правила ролей — SPEC-0004.
6. **Один рабочий экран сотрудника — чат**; командный центр удалён (сводка может вернуться отдельным решением).
7. **`Product`** был скрыт из интерфейса; впоследствии удалён полностью (ADR-0023).
8. **Знания — общая библиотека организации**, области видимости упразднены; агент использует только явно выбранные и включённые знания.
9. **P2P-звонки сохраняются** (ADR-0010).
10. **Онбординг за минуты**: создать агента → подключить точку входа → пригласить сотрудников → работать в чате. Любое решение, удлиняющее путь обязательными шагами, требует обоснования.
Тарифная привязка «один агент = одно оплаченное место» и сохранение коммерческого контура отменены (ADR-0020); фиксированные группы «Операторы»/«Поддержка», обязательная группа агента и её правила видимости заменены (ADR-0021).
## Последствия
- Продукт проще: меньше сущностей, разделов и понятий.
- Демо-датасет и seed очищены от коммерческих данных.
@@ -0,0 +1,25 @@
---
id: "0020"
title: Open-source распространение и удаление тарифного контура
status: accepted
date: 2026-09-28
---
## Контекст
Переход в контакт-центр (ADR-0019) сохранил коммерческий контур платформы: тарифы, подписки, entitlements, quotas, managed AI-кредиты и оплату через Точку. Владелец принял стратегию: продукт публикуется как open source для набора аудитории и известности, монетизация — отдельный облачный сервис voice-to-voice звонков. Тарифная модель в этой стратегии не нужна и удорожает продукт и порог входа.
## Решение
1. **Open source — основной способ распространения** (self-hosted, docker compose). Мультитенантность сохраняется. Managed-облако как сервис не входит.
2. **Тарифный контур удалён полностью**: планы, подписки, entitlements, quotas, usage-биллинг, managed AI-кредиты, интеграция с Точкой, все тарифные ограничения (лимиты диалогов, подключений, «места» агентов). Остаются только rate limits, защита от злоупотреблений и технический учёт AI-потребления для статистики. Количество агентов не ограничено.
3. **AI — только через провайдера организации** (ADR-0008, ADR-0014); managed AI и платформенные credentials удалены.
4. **Монетизация — облачный сервис voice-to-voice звонков с ИИ**: закрытый, со своим названием и поминутными тарифами, открыт для любых клиентов. Телефония, медиа, голосовые модели, тарифы и учёт минут живут только в нём. Chatballs подключает его как интеграцию по API-ключу; в открытом репозитории — только клиентская часть (ключ, привязка номеров к агентам, приём событий звонка: контекст агента, инструменты, транскрипты, передача оператору). Без оплаченного ключа интеграция ничего не делает. В открытой части остаются P2P-звонки и голосовые сообщения.
5. **Имя продукта — Chatballs**; прежние имена не используются ни в интерфейсе, ни в текстах, ни в идентификаторах. Зашитых доменов нет.
6. **Проектная документация — приватный контур** владельца, исключена из репозитория; в open-source входит отдельная публичная документация.
## Последствия
- Снесены `subscriptions`, managed AI и связанные platform-разделы; tenant provisioning создаёт организацию без подписки и usage period.
- Демо-датасет и seed очищены от тарифных данных.
- Интеграция с облачным сервисом voice-to-voice пока не реализована (T-006).
@@ -0,0 +1,24 @@
---
id: "0021"
title: Настраиваемые группы организации и маршрутизация диалогов
status: accepted
date: 2026-09-28
---
## Контекст
Отделы были заменены двумя фиксированными группами «Операторы» и «Поддержка», что предполагало универсальность деления на «новых» и «существующих» клиентов. Решение владельца: у каждой организации свои отделы, платформа не навязывает структуру. При этом группы не должны быть обязательным шагом запуска.
## Решение
1. **Группы задаются организацией**: tenant-сущность `EmployeeGroup` (имя, цвет, состав). Создаёт, переименовывает и удаляет администратор или владелец; количество не ограничено; групп может не быть. Группа — только граница видимости диалогов: без прав, знаний, навигации, должностей и иерархии. Сотрудник может состоять в нескольких группах.
2. **Группа агента необязательна**: агент с группой — его новые диалоги попадают в неё; без группы — диалоги видны всем.
3. **Диалог маршрутизируется независимо**: наследует группу агента, но его можно перенести в другую группу или убрать из группы; у диалога есть необязательный ответственный. Перенос и смена ответственного доступны администратору, владельцу и сотрудникам, видящим диалог.
4. **Видимость:** владелец и администратор — все диалоги; сотрудник — диалоги своих групп + без группы + где он ответственный (независимо от группы).
5. **Онбординг без групп** — полноценный сценарий: все видят всё; группы включаются, когда организация дорастает.
## Последствия
- Модель: `EmployeeGroup` (organization, name), M2M «сотрудник—группа», nullable группа агента, nullable `Conversation.group` (наследуется, переопределяется), nullable `Conversation.assignee`.
- Проверка видимости — deny-by-default во всех HTTP, WebSocket и фоновых операциях.
- UI: «Группы» в настройках; выбор группы в карточке агента; перенос и ответственный в диалоге; фильтр списка диалогов.
@@ -0,0 +1,26 @@
---
id: "0022"
title: Визуальные темы порталов поддержки
status: accepted
date: 2026-09-28
---
## Контекст
Публичный Help Center собран в `apps/internal-ui` и рисовался токенами дизайн-системы рабочего пространства (`--n-*`, `--surface-*`). Организации нужен другой облик публичной страницы, разработчикам — возможность добавлять оформления, не трогая бэкенд и чужие файлы. Требования: добавление темы без изменений БД, API и реестров; тема не может сломать вёрстку; отсутствие темы в сборке не ломает сохранённые порталы.
## Решение
1. **Тема — папка** `apps/internal-ui/src/features/help-center/themes/<id>/` с `manifest.ts` (имя, описание, схемы, цвета превью) и `theme.css`. Реестр — автообнаружением (`import.meta.glob`); id совпадает с именем папки (проверяет тест).
2. **Контракт токенов** `themes/contract.css` объявляет все `--help-*` со значениями по умолчанию. Вёрстка Help Center читает **только** их; `--n-*`, `--surface-*` и литеральные цвета в help-CSS запрещены. Свобода темы: переопределение токенов; собственные правила под `html[data-portal-theme="<id>"]`; подмена компонентов и JS не поддерживаются. Тест каталога проверяет скоуп, префикс `@keyframes` и то, что объявлены только токены контракта.
3. **Бэкенд не знает каталог тем**: `SupportPortal.theme` (идентификатор), `theme_scheme` (`LIGHT | DARK | SYSTEM`), `theme_settings` (JSON, зарезервирован). Проверяется только формат id. Неизвестная тема деградирует до `classic` на публичной странице и показывается как недоступная в настройках — без молчаливой подмены выбора.
4. **Цветовая схема** — атрибут `data-theme` на `<html>`, тот же механизм, что у интерфейса; `SYSTEM` следует `prefers-color-scheme`; неподдерживаемая темой схема не предлагается, сохранённая деградирует до основной.
5. **Загрузка:** манифесты статичны, CSS темы ленив; до применения темы портал держит boot-загрузчик — без вспышки оформления.
Тема по умолчанию `classic` повторяет базовое оформление и ничего не переопределяет.
## Последствия
- Публичный манифест `/api/v1/help/` отдаёт `theme`, `themeScheme`, `themeSettings`.
- Вёрстка Help Center переведена на `--help-*` без визуальных изменений.
- Сами темы не разрабатываются этим решением; визуальные решения за владельцем. Инструкция разработчику — документ «Разработка визуальной темы портала» («portal-theme-development.md»).
@@ -0,0 +1,24 @@
---
id: "0023"
title: Удаление сущности Product и авторизованного in-product чата
status: accepted
date: 2026-09-28
---
## Контекст
После перехода в контакт-центр `Product` остался в схеме как скрытая техническая привязка, но скрытым не стал: в настройках портала жил раздел «Продукты и поддержка», в списке порталов — колонка «Продукты». На `Product` держался целый контур: контракты идентификации клиента поддержки, снимки личности, Product Support Token, режим виджета `AUTHENTICATED_PRODUCT`, признак канала `requires_authenticated_product_identity`, XOR-инвариант identity диалога, `mode=support` лоадера и отдельное приложение виджета. Контакт-центр не знает о продуктах клиента — у контура нет владельца в продуктовой модели.
## Решение
1. **`Product` удалён полностью** (приложение `products`, таблица `identity_product`); ссылки сняты с канала, учёта AI и портала.
2. **Авторизованный in-product чат удалён**: приложение `support`, режим `support` веб-чата, `ChatballsChat.init` с token provider, поле `WebChatWidget.mode` и ключ `mode` в ответах API. Внешний продукт приводит клиента в поддержку обычным анонимным виджетом.
3. **У диалога один источник identity — контакт**; диалоги из снимков личности получили контакты.
4. **Политика канала** — только `allow_anonymous_sessions` и `allow_self_reported_contact`; коммерческие флаги и инварианты политики удалены (`channels/0008`); удалены остатки налогового профиля организации (`identity/0032`).
5. **Портал не знает о продуктах**: раздел, колонка, связь и endpoint продуктов удалены; выбор виджета — `GET /portals/{id}/widgets/`, только анонимные виджеты.
6. **Данные не экспортируются** — решение владельца; таблицы сняты миграциями, следы удалённых приложений вычищены из миграций уцелевших.
## Последствия
- У виджета нет понятия «режим»; возврат авторизованного входа потребует нового проектирования без сущности «продукт».
- Дизайн-baseline «Порталы» приведён в соответствие.
@@ -0,0 +1,26 @@
---
id: "0024"
title: Администратор установки и настройки инсталляции
status: accepted
date: 2026-09-28
---
## Контекст
Настройки установки — публичный адрес, почта, TURN, хранилище, язык по умолчанию — общие для всех организаций, но жили под путём организации и редактировались любым с `company.manage` хотя бы в одной организации: владелец одной организации переключал SMTP, домен и бакет всем остальным. Роль platform administrator была заявлена, но не существовала; мастер выдавал владельцу `is_superuser` — break-glass доступ к Django admin, а не продуктовую роль.
## Решение
1. **`HumanUser.is_instance_admin`** — глобальный булев признак; не выводится из членств и не даёт прав внутри организаций. Первым получает владелец из мастера; существующим установкам выдан миграцией по `is_superuser`. Передача — вне интерфейса: `set_instance_admin --email … --grant|--revoke` и Django admin; последнего активного администратора отозвать нельзя. Экрана управления администраторами нет (нет в baseline).
2. **Настройки установки — путь без организации** `/api/v1/instance/`: `settings/`, `settings/email-check/`, `storage/`, `storage/check/`, `storage/migrate/`. Без tenant middleware. Чтение — менеджеру любой организации (для карточки TURN), изменение — только администратору установки. Прежние пути отвечают 404.
3. **Аудит и события уровня установки** — с `organization_id IS NULL`, как platform-события.
4. **Интерфейс:** «Платформа» и «Хранилище файлов» в «Настройках» видны только администратору установки; сессия отдаёт `isInstanceAdmin`; остальным менеджерам relay только на чтение.
5. **Создание организаций из интерфейса** (с 1.6.0): кнопка «Добавить организацию» в переключателе видна администратору установки и тем, кто где-либо владелец или администратор; создавший становится владельцем. После входа пользователь нескольких организаций выбирает, с какой начать.
Отклонены: переиспользовать `is_superuser` (открывает admin и обход проверок); оставить настройки под организацией с проверкой признака.
## Последствия
- `is_superuser` — только break-glass Django admin.
- SMTP, TURN и хранилище общие на установку — по решению.
- Экран управления организациями у администратора установки отсутствует (T-005).
@@ -0,0 +1,26 @@
---
id: "0025"
title: Приглашения в организацию
status: accepted
date: 2026-09-28
---
## Контекст
Учётная запись глобальна, членство — на организацию, но попасть во вторую организацию было нельзя: форма создания сотрудника отвечала «e-mail занят» и заодно раскрывала чужим организациям, какие адреса зарегистрированы. Приглашение владельца из провижининга существовало, но письмо никто не отправлял, а регистрации по ссылке не было.
## Решение
1. **Членство — только с согласия человека.** Существующая учётная запись никогда не присоединяется молча; администратор лишь выписывает приглашение.
2. **Форма создания сотрудника не меняется.** Новый адрес — учётная запись с паролем первичного доступа. Адрес активной учётной записи из другой организации — приглашение с той же ролью, должностью, телефоном и группами и событие на отправку письма. В своей организации (и для деактивированной учётной записи) адрес «занят». Членство собирается из полей приглашения при принятии; повторное приглашение заменяет прежнее.
3. **Письмо отправляет воркер, токен выпускается при отправке** — открытый токен нигде не хранится. Ссылка `/join?token=…`; язык — получателя, иначе организации. Тот же обработчик шлёт приглашение владельца из провижининга.
4. **Ссылка `/join`:** предпросмотр по токену; есть учётная запись — вход и автоматическое принятие; нет — форма имени и пароля из элементов мастера (`register`, для существующего адреса — `account_exists`). Для владельца принятие активирует организацию (`PENDING_OWNER → ACTIVE`). Приглашение до входа находится через каталог входа (исправлено в 1.6.0).
5. **Ожидающие приглашения в списке сотрудников** — строки «Приглашён» из baseline; фильтры действуют; из меню — «отправить ещё раз» (срок продлевается) и «отозвать». Требуют `employees.manage` и права выдать роль.
Отклонены: присоединять существующую учётную запись сразу; хранить открытый токен; отдельный экран приглашений.
## Последствия
- Аудит: `identity.employee_invited`, `identity.invitation_accepted`, `identity.invitation_revoked`.
- Ответ создания сотрудника может содержать `employee: null`, `invited: true`.
- Остаточная различимость: администратор видит, что ушло приглашение, то есть узнаёт о существовании учётной записи. Полное закрытие требует перевода и новых сотрудников на приглашения, что меняет кадры baseline и требует отдельного решения.
@@ -0,0 +1,27 @@
---
id: "0026"
title: Минимальные привилегии runtime-процессов
status: accepted
date: 2026-09-28
---
## Контекст
Аудит поддержки нескольких организаций показал, что граница изоляции прочна в базе, но размыта на уровне процессов: платформенный API создания организаций не работал в поставке (роль platform не имела прав на свои таблицы, тесты ходили владельцем кластера); публичный `backend-app` держал platform-соединение, а в его файловой системе лежали пароли всех ролей, включая роль миграций; роль app читала таблицу организаций без ограничений.
## Решение
1. **Гранты платформенной роли** на таблицы оператора, токена, провижининга (без DELETE) и приглашений под tenant-политикой. Провижининг проверяется тестом под реальной runtime-ролью.
2. **`backend-app` работает только ролью app.** Каталоги входа (`chatballs.*_directory`) — security-barrier вьюхи с SELECT для app. Организацию роль app вставляет только в её собственном контексте (`id = chatballs.current_organization_id()`): id резервируется из последовательности заранее (`tenancy.lookup.reserve_organization_id`) — так работают и мастер первого запуска, и «Добавить организацию». Алиас `platform` есть только у `backend-platform` и воркеров (`CHATBALLS_DB_PLATFORM_ALIAS=1`); в остальных процессах обращение к нему падает сразу.
3. **Три тома секретов:** `chatballs-secrets` (ключ подписи, ключ шифрования, пароль app, секрет TURN — все сервисы); `chatballs-secrets-platform` (пароль platform — secrets, postgres, backend-platform, воркеры); `chatballs-secrets-schema` (пароли migration и владельца кластера — secrets, postgres, init, backend-admin). Генератор переносит файлы существующих установок (копия, сверка, удаление), пароли не меняет. `doctor` проверяет, что `backend-app` не видит паролей platform и migration. Вариант `volume.subpath` отклонён (требует Docker 26 и Compose 2.24).
4. **Строка организации видна app только в её контексте**; входы без контекста находят id через `chatballs.organization_directory` и читают строку внутри `tenant_atomic`.
5. **nginx разрешает upstream на каждом запросе** через `resolver` на DNS Docker — иначе после пересоздания контейнера WebSocket уходил в чужой сервис.
Отклонены: платформенные учётные данные в `backend-app` «на всякий случай»; INSERT организаций для app без ограничения; расширение каталога организаций именем и логотипом.
## Последствия
- Резервная копия включает три тома секретов.
- Откат на релиз до разделения томов требует вернуть файлы в общий том (команда — в истории релизов).
- Тесты бэкенда в dev-стеке — из контейнера `backend-admin`.
- `backend-admin` по-прежнему обходит RLS ролью миграций; его защищает привязка к loopback.
@@ -0,0 +1,24 @@
---
id: "0027"
title: Обновление установки из интерфейса
status: accepted
date: 2026-09-28
---
## Контекст
Установка обновлялась только из консоли сервера; администратор не узнавал о новых версиях иначе как со страницы релизов, самообслуживание без SSH было невозможно. `backend-app` работает с минимальными правами (ADR-0026), и давать ему управление Docker нельзя.
## Решение
1. **Канал релизов — GitHub Releases** репозитория поставки (`CHATBALLS_UPDATE_REPO`, по умолчанию `dartdavros/chatballs`). Версия зашивается в образ бэкенда (`CHATBALLS_VERSION`; у dev-сборки `dev` обновлений нет). Релиз принимается только с ассетом `compose.yaml` по адресу `…/releases/download/v<версия>/compose.yaml`; версии сравниваются семантически. Проверка — из воркера раз в `CHATBALLS_UPDATE_CHECK_INTERVAL_SECONDS` (15 минут) и по кнопке; результат — в одной строке `UpdateState` вне организаций; ошибка канала ничего не роняет.
2. **Отдельный сервис `updater`** (образ на `docker:cli` с двумя скриптами) — единственный с `/var/run/docker.sock`. Обмен с `backend-app` — файлами в томе `chatballs-updates`: `heartbeat` (каждые 5 с; старше 90 с — «обновление недоступно»), `request.json`, `status.json` (`running`/`done`/`failed` со стадиями), `apply.log`. Updater принимает только цифровую версию, адрес со страницы релизов своего репозитория и `compose.yaml`, где все образы пришпилены `@sha256` из репозитория поставки; проект, каталог и том узнаёт из меток своего контейнера.
3. **Установка в отдельном контейнере** `chatballs-updater-apply` (тот же образ, `docker run -d --rm`, собственная входная точка установки): `config -q` → `pull` → `up -d --wait`. После успеха новый `compose.yaml` копируется в каталог установки. Второй запрос во время установки отклоняется; установка, оборвавшаяся без результата, помечается неудачной.
4. **Интерфейс:** баннер администратору установки «Доступна версия X» со ссылкой «Что нового», кнопкой «Обновить» и подтверждением; баннер переживает перезапуск бэкенда и показывает стадию, итог или причину сбоя. То же и кнопка «Проверить» — в карточке «Обновления» раздела «Платформа». Аудит `updates.install_requested`. Читать — владелец любой организации, проверять и устанавливать — администратор установки.
Отклонены: Docker-сокет у `backend-app`; Watchtower и аналоги; проверка версии из браузера напрямую в GitHub.
## Последствия
- `docker.sock` у `updater` равен root на хосте; поверхность сведена к минимуму. Без сервиса интерфейс показывает «обновление недоступно», CLI-путь остаётся.
- Откат — вручную (`compose.yaml` предыдущего релиза); автоматического отката нет — миграции не возвращаются.
@@ -0,0 +1,24 @@
---
id: "0028"
title: "Отделение хода AI от приёма сообщений: сервис worker-events"
status: accepted
date: 2026-09-28
---
## Контекст
Агент с несколькими подключениями (сайт и мессенджер) отвечал с задержками или молчал. Ответ считался прямо в приёме сообщения: пока модель думала над одним вопросом, ни одно другое входящее по всей установке не забиралось; один ход мог занять до трёх минут. Отозванный ключ одной организации мог погасить AI у остальных.
## Решение
- Приём (`worker --role=poller`) только записывает сообщение; ход AI, расшифровка голосовых, доставка уведомлений и приглашений на звонки — событиями outbox, которые обрабатывает отдельный сервис `worker-events` (`run_worker --role=events`).
- `worker` (опрос) — строго один экземпляр: курсоры и паузы после сбоя живут в памяти процесса.
- `worker-events` масштабируется репликами (`CHATBALLS_EVENT_WORKERS`, по умолчанию 2); события разбираются с `skip_locked`; ходы одного диалога идут строго по очереди (`claim_next_outbox_event`); ход упавшего процесса возвращается в очередь. Реплик держать немного — они ходят к провайдеру одним ключом организации.
- У сообщения есть состояние хода AI; ответ ограничен сроком: не успел или пролежал в очереди слишком долго — диалог уходит оператору, клиент получает понятный текст.
- Отказ провайдера по существу запроса (неверный ключ, несуществующая модель, слишком длинный запрос) не повторяется; сбои провайдера считаются по каждой организации отдельно.
- Подключение с сорвавшимся опросом возвращается в работу через минуту.
## Последствия
- Каналы не ждут друг друга; разносить подключения по агентам ради обхода залипания не нужно.
- В установке появился сервис `worker-events`; миграция `conversations/0026`.
@@ -0,0 +1,22 @@
---
id: "0029"
title: Модели ответов и расшифровки голосовых принадлежат агенту
status: accepted
date: 2026-09-28
---
## Контекст
Речь в текст умеет не всякая модель ответов: у части провайдеров (например, Anthropic, Yandex Foundation Models) эндпоинта расшифровки нет вовсе. Кроме того, на одном ключе провайдера живут разные агенты, и модель им нужна разная, а модель задавалась на интеграции.
## Решение
- На карточке агента две независимые пары «провайдер + модель»: для ответов и для расшифровки голосовых. Расшифровку можно направить в любую другую интеграцию организации (OpenAI, Groq, собственный Whisper), ответы при этом пишет основной провайдер.
- Модель принадлежит агенту, а не ключу. Пустое поле означает «как в интеграции»; подсказка показывает значение по умолчанию.
- Модель расшифровки: своя на агенте → из интеграции расшифровки → `whisper-1`. Пустой выбор интеграции расшифровки — «как у ответов».
- Ошибка расшифровки не показывает оператору ответ чужого API: в ленте — фраза о том, что произошло и где чинить; ответ провайдера — в журнал.
## Последствия
- Миграции `ai/0020`, `ai/0021`; `ai/0022` освободила поле модели у агентов, чья модель совпадала с моделью интеграции, — они следуют за настройкой ключа.
- Маршрутизация по-прежнему только через явно выбранные интеграции агента (ADR-0008).
@@ -0,0 +1,30 @@
---
id: "0030"
title: Данные с сайта — недоверенные поля контакта в контексте агента
status: accepted
date: 2026-09-29
---
## Контекст
Организациям нужно, чтобы оператор и AI-агент видели в веб-чате то, что сайт уже знает о посетителе: ID клиента, номер и статус заказа, признак активного заказа. Значения меняются по ходу диалога.
Данные приходят из браузера посетителя через публичный виджет: их может подменить любой, кто откроет консоль. ADR-0004 требует минимизировать данные, передаваемые LLM. ADR-0003 определяет идентификацию клиента и объединение контактов. Встроенные поля контакта (имя, почта, телефон) оператор правит вручную, и эти правки нельзя затирать.
Варианты: (1) писать всё в поля Contact или в JSON на контакте; (2) отдельное хранилище значений со схемой в настройках подключения; (3) подписанный токен от сервера сайта (JWT) — надёжно, но требует бэкенда у сайта и ломает запуск за минуты.
## Решение
1. Схема своих полей — в конфигурации WEB-подключения (`integration.config.fields`): ключ, подпись, тип (string, number, boolean, datetime, enum, email, phone, url), значения списка с цветом, признак «Видит AI», порядок. Не больше 30 полей. Ключи `name`, `email`, `phone` зарезервированы.
2. Значения — в отдельной таблице `contact_field_values (organization, contact, integration, key, value jsonb, updated_at)`: одно последнее значение на ключ, с RLS как у остальных tenant-таблиц. Историю изменений хранит лента диалога (системные события), а не эта таблица.
3. `name`, `email`, `phone` пишутся в контакт, только если поле в контакте пустое или прошлое значение пришло с того же подключения. Ручные правки оператора не затираются.
4. Данные с сайта недоверенные. Они не участвуют в авторизации, идентификации и объединении контактов (ADR-0003). В UI помечены «с сайта» и доступны только для чтения. Неизвестные ключи и значения неверного типа отбрасываются молча для сайта, с предупреждением в журнал.
5. В системный промпт агента попадают только поля с включённым «Видит AI». Подаются отдельным блоком как сведения от сайта, не как инструкции. Это явное согласие администратора в духе ADR-0004.
6. Удаление поля из схемы значения не удаляет, но перестаёт их показывать и передавать AI.
7. Подписанная передача (JWT от сервера сайта) — возможное развитие, в этот объём не входит.
## Последствия
Плюсы: оператор и AI видят контекст сайта без интеграций на стороне бэкенда сайта: достаточно одного вызова JS. Схема живёт рядом с виджетом и не требует миграций на каждое новое поле. Карточка контакта не засоряется, ручные правки защищены.
Минусы и риски: посетитель может подменить значения, поэтому AI может ответить по ложному статусу. Администратор должен понимать это, включая «Видит AI». Значения одного контакта из разных подключений хранятся раздельно по `integration`. Поиск и фильтрация контактов по этим полям в объём не входят. Появляется ещё одна tenant-таблица, для неё нужны тесты изоляции.
+215
View File
@@ -0,0 +1,215 @@
# Архитектура Chatballs
## 1. Принципы
- **Одна кодовая база, одна правда.** От одиночной установки до многоорганизационной работают один backend, один frontend, один `compose.yaml` и один порядок обновления. Отдельных редакций нет.
- **Модульный монолит.** Домены — логические модули одного Django-приложения. Микросервисов и второй базы нет. Фоновые обработчики — отдельные процессы из того же кода.
- **Organization — граница изоляции.** Все клиентские данные принадлежат организации прямо или через однозначную цепочку владения. `organization_id`, переданный клиентом, никогда не основание доступа (ADR-0012).
- **Один пользователь — несколько организаций.** Учётная запись глобальна, связь с организацией — через membership.
- **Простота для администратора.** Понятий минимум: агент, подключение внутри агента, сотрудник, группа, контакт, диалог, знание, портал.
- **Никакой коммерции.** Ни продаж, ни заказов, ни платежей, ни собственного биллинга (ADR-0020).
- **Ничего не спрашивать до запуска.** Установка не требует переменных окружения; всё, что зависит от площадки, задаётся в интерфейсе и хранится в БД (ADR-0011).
## 2. Верхнеуровневая модель
```text
Platform
├── Platform layer
│ ├── HumanUser, Organization, OrganizationMembership
│ ├── InstanceSettings — свойства самой установки
│ ├── tenant provisioning
│ └── platform administration
└── Organization workspace
├── сотрудники, роли и группы
├── агенты и их подключения
├── контакты, диалоги, сообщения, шаблоны ответов
├── звонки
├── знания и retrieval
├── порталы поддержки
└── уведомления и аудит
```
## 3. Структура репозитория
```text
code/chatballs/
├── apps/
│ ├── backend/ Django: API, ASGI, домены, фоновые воркеры
│ ├── internal-ui/ рабочее пространство организации и публичные порталы (по HTTP Host)
│ └── web-chat/ публичный веб-виджет, клиентская страница звонка, лоадер
├── packages/
│ ├── ui/ общие компоненты и токены
│ ├── contracts/ общие типы и контракты API
│ └── shared/ общая TypeScript-логика
├── design/baseline/ утверждённые макеты
├── deploy/ cli, docker, nginx, postgres, secrets
├── scripts/ запуск и проверки
├── tests/ сквозные тесты и тесты CLI
├── compose.yaml единственный production-манифест
├── compose.dev.yaml override для разработки
└── Caddyfile
```
Решение — ADR-0005.
## 4. Модули backend
| Модуль | Ответственность |
|---|---|
| `identity` | пользователи, организации, membership, роли, группы, приглашения, мастер первого запуска, настройки установки, аудит, демо-датасет |
| `tenancy` | tenant-контекст, транзакции с RLS, маршрутизация ролей БД, каталоги входа, учёт объёма хранилища |
| `channels` | якорь обработки: группа диалогов агента, ссылка на провайдер-интеграцию |
| `ai` | агент, знания, категории, вложения, фрагменты и retrieval, учёт вызовов LLM, расшифровка голосовых |
| `integrations` | LLM-провайдеры (OpenRouter, Custom, Demo) и подключения-транспорты (Telegram, MAX, VK, WEB, EMAIL), секреты, проверки |
| `conversations` | контакты, идентичности подключений, диалоги, сообщения, метки, шаблоны ответов, транспорты приёма и отправки |
| `calls` | P2P-звонки: сессии, приглашения, участники, метрики |
| `webchat` | веб-виджет: конфигурация, публичный лоадер, анонимные сессии |
| `support_portals` | порталы, категории, статьи и редакции, публичная выдача, обратная связь |
| `notifications` | уведомления, прочтения, привязки служебных ботов |
| `platform` | platform-операторы, токены, provisioning организаций |
| `events` | inbox/outbox, идемпотентная доставка, очередь хода AI |
| `updates` | состояние обновлений установки |
| `health`, `http`, `api` | health-эндпоинты и общая инфраструктура HTTP |
### Агент и канал
Пользователь видит один объект — **Агента**. В схеме это пара `Channel` — `AIAgent` один к одному: канал держит группу видимости и ссылку на провайдер-интеграцию, агент — имя, инструкции, модель, знания и статьи. Подключения привязаны к каналу. Это осознанный остаток объединения (ADR-0019): слияние выполнено в продуктовой модели и UI, но не в схеме БД. В API и UI «канал» как самостоятельный объект не появляется.
## 5. Пользователи и доступ
`HumanUser` — глобальная identity (email, аутентификация, MFA, глобальный статус, признак `is_instance_admin`). `OrganizationMembership` — участие в организации: роль, должность, статус, блокировка; `unique(user, organization)`.
```text
OWNER — все права; не может быть удалён или заблокирован
ADMIN — те же права, включая управление администраторами
EMPLOYEE — только чат; видимость диалогов по группам и назначению
```
Видимость диалога для сотрудника: диалоги его групп + без группы + где он ответственный (ADR-0021). Проверки — deny-by-default в backend во всех HTTP-, WebSocket- и фоновых операциях.
Администратор установки — глобальный признак, не выражаемый через membership и не дающий прав внутри организаций (ADR-0024). Доступ оператора платформы к данным организации — только через аудируемую support-сессию.
## 6. Tenant context и изоляция
```text
аутентифицированный HumanUser + активная membership + выбранная организация = проверенный tenant context
```
Канонический маршрут tenant API: `/api/v1/organizations/{organization_public_id}/...`. Настройки установки — вне организаций: `/api/v1/instance/...`.
Слои изоляции: проверка membership, роли и видимости в сервисах → валидация принадлежности в моделях → ограничения БД (включая constraint triggers) → PostgreSQL RLS → регрессионные тесты изоляции.
RLS: `ENABLE` и `FORCE ROW LEVEL SECURITY`, политики с `USING` и `WITH CHECK`, runtime-роль без `BYPASSRLS` и без владения таблицами; контекст — только `SET LOCAL` в транзакции. Строка организации видна роли app только в её контексте; входы без контекста идут через security-barrier каталоги (ADR-0026).
Ключи объектов в хранилище: `organizations/{organization_public_id}/...`.
## 7. Коммуникации
Диалог — основная единица; состояние разделено на оси: жизненный цикл, режим управления, ожидаемый отвечающий (ADR-0001). AI ведёт первым, человек перехватывает атомарно (ADR-0002). Диалог наследует группу агента, но маршрутизируется независимо.
Приём и отправка — через идемпотентные inbox/outbox. Приём только записывает сообщение; ход AI считается отдельно процессами `worker-events`, по очереди внутри диалога (ADR-0028).
P2P-звонок — дочерняя сессия диалога; медиа идёт WebRTC напрямую либо через Coturn и не сохраняется (ADR-0010).
## 8. AI
Текстовый режим — только через провайдера организации: OpenRouter или любой OpenAI-совместимый endpoint; плюс демо-провайдер без ключей и сети. На агенте две независимые пары «провайдер + модель»: для ответов и для расшифровки голосовых (ADR-0029).
Знания — общая библиотека организации с деревом категорий, без версий и релизов (ADR-0009). Retrieval — в PostgreSQL: `pgvector` + встроенный FTS (ADR-0007). Статья портала — второй источник фрагментов (ADR-0017). Изменения агента и знаний применяются сразу.
## 9. Поверхности приложения
```text
backend-app — tenant API, публичные API, realtime
backend-platform — platform control plane (отдельный домен)
backend-admin — Django admin, только на loopback
```
У каждой поверхности свой URLConf, свои cookie и host-политика (ADR-0013). Публичные порталы определяются по HTTP Host до разбора маршрута.
## 10. Развёртывание
```text
secrets · postgres · redis · init · backend-app · backend-platform · backend-admin
worker · worker-events · updater · frontend · gateway · coturn
```
- `secrets` — одноразовая генерация секретов в три тома (`chatballs-secrets`, `-platform`, `-schema`);
- `init` — одноразовые миграции;
- `worker` — опрос подключений и периодические работы, строго один экземпляр;
- `worker-events` — ход AI, доставка уведомлений и приглашений, масштабируется репликами (`CHATBALLS_EVENT_WORKERS`, по умолчанию 2);
- `updater` — единственный сервис с `docker.sock`, применяет обновления из интерфейса;
- `frontend` — nginx со статикой internal-ui и веб-чата;
- `gateway` (Caddy) — единственная публичная HTTP/HTTPS-граница, без зашитых хостов, сертификаты on-demand для одобренных backend хостов;
- `coturn` — relay для звонков, поднимается вместе со стеком (host network, порт 3478, UDP 49160–49999, TURN-over-TLS по умолчанию выключен);
- PostgreSQL (`pgvector/pgvector:pg16`) и Redis портов на хост не публикуют.
Подробности — SPEC-0005.
## 11. Источники истины
| Область | Источник |
|---|---|
| Пользователи, организации, membership | platform layer |
| Адрес установки, почта, TURN, хранилище, язык по умолчанию | `InstanceSettings` |
| Секреты интеграций | зашифрованные поля БД организации |
| Контакты, диалоги, агенты, знания, порталы | organization workspace |
| Файлы и вложения | локальный диск установки или S3-совместимое хранилище |
Часовой пояс и локаль — настройки организации.
## 12. Надёжность и безопасность
Обязательны: transactional outbox/inbox и идемпотентные обработчики, retry с backoff, сохранение необработанных событий, резервное копирование и проверяемое восстановление, регрессионные тесты изоляции, шифрование и ротация секретов, минимизация payload для LLM (ADR-0004), краткоживущие call/TURN credentials.
## 13. Границы
Входит: open-source self-hosted поставка; несколько организаций в установке; контакты и история; AI-first диалоги; агенты с подключениями Telegram, MAX, ВКонтакте, веб-виджет и email; группы, перенос диалогов и ответственный; голосовые сообщения; P2P-звонки; знания и retrieval; порталы с темами; шаблоны ответов; уведомления; аудит; раздельные app/platform поверхности и local-only admin; обновление из интерфейса.
Не входит без отдельного решения: тарифы, подписки, квоты, биллинг; managed AI и AI-кредиты; продажи, заказы, каталог; лиды, сделки, воронки, канбан; должности и capability-модель как наборы прав; командный центр; managed-облако; групповые звонки, SFU, screen sharing и запись медиа.
## Правила и ограничения
### Изоляция и безопасность
1. Каждый endpoint, selector, сервис и репозиторий принимает проверенный tenant-контекст; объект загружается по идентификатору **вместе** с организацией. Отсутствие контекста — отказ, а не запрос по всей базе.
2. `organization_id` из тела, query string или клиентских метаданных — не доказательство доступа. Публичные точки входа выводят организацию server-side из credential или ключа виджета.
3. Session-level `SET` запрещён; tenant-контекст только через `SET LOCAL` в транзакции (`tenant_atomic`). Для WebSocket, воркеров и обработчиков событий контекст ставится на каждую операцию.
4. `backend-app` работает только ролью app; алиас `platform` — только у `backend-platform` и воркеров. Docker socket в контейнеры приложения не монтируется (исключение — `updater`).
5. Cross-organization связи запрещены и защищаются всеми слоями; пропущенный tenant-ключ ловится тестом изоляции под реальной runtime-ролью.
6. В прикладных логах и БД запрещено хранить raw audio, SDP, ICE candidates, секреты провайдеров, токены сессий и приглашений. Секретные параметры URL не попадают ни в журнал, ни в поле последней ошибки подключения.
7. Провайдер AI разрешается только через явно выбранную интеграцию агента; «первая попавшаяся интеграция» и глобальная подмена выбора владельца запрещены. Браузер никогда не получает credentials провайдера.
8. Django admin — только loopback, без публичного проксирования. Cookie host-only, без `Domain`, `HttpOnly`; жёсткость (`__Host-`, `Secure`, HSTS) — по факту TLS.
9. Домены в код, конфигурацию и образы не зашиваются; frontend использует same-origin относительные URL.
### Поставка
10. Один `compose.yaml` для production и коробки; разработка добавляет только `compose.dev.yaml`. Второй production-манифест запрещён. Новые приложения и пакеты вне структуры репозитория — только отдельным решением.
11. Файла `.env` у продукта нет; новые настройки площадки задаются в интерфейсе и хранятся в БД. `release.env` содержит только версию и ссылки на образы по digest.
12. `init` выполняет только миграции: не создаёт организацию, владельца и демо-данные.
### UI (из AGENTS.md проекта)
13. **Макет — закон.** Экраны, блоки, тексты, порядок и состояния — ровно как в `design/baseline/<фича>/*.dc.html`. Отклонение — только по прямому указанию владельца.
14. **Один стандарт на элемент.** Ссылки — класс `.link` из `shared/links.css` с модификаторами `.is-strong`, `.is-neutral`, `.is-muted`, `.is-mono`, `.has-icon`; действие — `<button className="link">`. Выпадающие меню — только antd `Dropdown` с `overlayClassName="app-dropdown"` и триггером `.row-menu-button`. Перед написанием элемента искать существующий в `shared/` и соседних фичах. Порталы и База знаний используют общие компоненты библиотеки, а не копии.
15. **Никаких служебных записок в продукте.** На экране запрещены коды спек и решений, номера правил, слова «инвариант», «констрейнт», «миграция», имена таблиц, полей и enum-значений. Пользователю — следствие и способ исправить.
16. Все цвета — только через CSS-токены и тему antd; сырые hex в компонентах запрещены. Вёрстка Help Center читает только токены `--help-*`.
### Язык интерфейса (i18n)
17. Текста, который видит человек, в коде нет: строка живёт в словаре и подставляется через `t(...)` (фронтенд — `src/i18n` приложения, `packages/ui` — свой словарь; бэкенд — `chatballs/i18n`). Русский каталог — источник ключей, английский обязан его повторить.
18. Ключ — от смысла (`settings.storage_bucket_name`). Числа склоняет `tn(...)`, даты, размеры и разряды форматирует `fmt`; прибитые `"ru-RU"` запрещены.
19. Записанное в базу хранит код, фразу собирает бэкенд на языке читателя. На бэкенде `t(...)` не вызывается на уровне модуля.
20. Текст наружу (клиенту, письмо, кнопка виджета) — на языке организации: `t(..., language=customer_language(organization))`. Язык запроса: профиль → организация → установка → браузер. Системный промпт агента не переводится.
21. Демо-набор — по языкам в `demo_seed/data/<язык>/` с одинаковыми наборами ключей.
### Код
22. DRY, KISS, SRP, separation of concerns, **NO GOD FILES**: компонент больше ~200 строк или смешивающий layout, state, data и subviews — разделить; файл больше ~300 строк из-за текущей работы — вынести части. Page-компоненты — преимущественно composition-only.
23. Если реализация конфликтует с этими правилами — остановиться и сообщить blocker.
### Процесс
24. Без явного указания владельца («делай») ничего не менять. Не менять, не сбрасывать и не пересоздавать учётные данные, пароли, TOTP, сессии и права; `bootstrap_owner`, seed пользователей, смена ролей — только с отдельного подтверждения.
25. Любое изменяющее действие в production — только после согласования точного плана миграции с владельцем.
26. Тесты — только относящиеся к изменению текущего шага, с `--reuse-db`; полный прогон (~20 минут) — только по явному указанию. Два прогона на одной тестовой базе одновременно нельзя. Тесты бэкенда в dev-стеке запускаются из контейнера `backend-admin`.
27. Если инструмента (Python, PHP) нет в окружении — использовать docker проекта. Использовать `gh`/`glab`, соблюдать инструкцию по коммитам.
### Документация
28. Документ описывает то, что есть. Разошлись код и документ — чинится одно из двух; известные расхождения выносятся в отдельный раздел.
29. Отменённое удаляется целиком; одно решение — один документ.
30. Облачный сервис voice-to-voice здесь не описывается — только интеграция с ним.
+58
View File
@@ -0,0 +1,58 @@
# Chatballs — бриф
## Что это
Chatballs — open-source платформа клиентских коммуникаций: контакт-центр, в котором AI-агенты и сотрудники организации ведут переписку и разговоры с клиентами. Платформа объединяет организации, сотрудников и их группы, AI-агентов, клиентские подключения, контакты, диалоги, звонки, знания и порталы поддержки.
Продукт **не является CRM продаж**: сделки, воронки, заказы, счета, каталог и учёт выручки в модель не входят и без отдельного решения не будут добавлены (ADR-0019).
## Для кого
Организации любого размера, которым нужен контакт-центр на собственной инфраструктуре: принимать обращения из мессенджеров (Telegram, MAX, ВКонтакте), почты и сайта, отвечать AI первым и передавать сложное сотрудникам.
Одна установка может обслуживать несколько изолированных организаций; один человек может состоять в нескольких организациях с разными ролями.
## Как распространяется
- Open source, self-hosted, разворачивается одной командой docker compose (ADR-0020).
- Все функции доступны в любой инсталляции: тарифов, подписок, квот и платных ограничений нет. Действуют только технические rate limits и защита от злоупотреблений.
- Монетизация — отдельный закрытый облачный сервис voice-to-voice телефонных звонков с ИИ со своими поминутными тарифами. Chatballs подключает его как интеграцию по API-ключу. В открытой части остаются P2P-звонки сотрудник–клиент и голосовые сообщения.
- Обновление установки — из интерфейса по кнопке или вручную через `compose.yaml` релиза (ADR-0027).
## Главный принцип — запуск за минуты
Человек поднимает контейнеры, открывает установку в браузере и попадает в мастер первого запуска: название организации, своё имя, e-mail, пароль, переключатель демо-данных. После «Начать» он внутри как владелец. Файла `.env` нет, задавать переменные негде: секреты генерирует первый старт, всё зависящее от площадки настраивается в интерфейсе.
Дальше — три шага чек-листа «Запуск»: создать агента → подключить точку входа (Telegram-бот или код веб-виджета) → пригласить сотрудников. Группы необязательны: без них все сотрудники видят все диалоги.
Любое проектное решение, удлиняющее этот путь обязательными шагами, требует отдельного обоснования.
## Ключевые понятия
- **Организация** — владелец, администраторы, сотрудники и их группы; агенты и подключения; контакты; диалоги и звонки; библиотека знаний; порталы; AI-провайдер.
- **Роли**: Владелец (всё; нельзя удалить и заблокировать), Администратор (те же права, включая управление администраторами), Сотрудник (только чат: видимые ему диалоги).
- **Группа** — только граница видимости диалогов; прав, знаний, должностей и иерархии не несёт.
- **Агент** — центральный объект: одновременно AI-собеседник и точка входа. Имя, инструкции, модель, знания, подключения (Telegram, MAX, ВКонтакте, веб-виджет, почтовый ящик), необязательная группа, признак активности. Количество агентов не ограничено.
- **Диалог** — основная единица коммуникации; внутри — голосовые сообщения, P2P аудио/видеозвонок, в перспективе — телефонный разговор с AI через облачный сервис.
- **Знания** — общая библиотека организации с деревом категорий; агент использует только явно прикреплённые и включённые материалы; статья портала может быть прикреплена как источник без копирования.
- **Портал поддержки** — публичная база статей с поиском, оценками и встроенным веб-чатом; порталов несколько, у каждого свой адрес и тема.
## Рабочие экраны
Сотрудник работает в одном окне — чате. Навигация владельца и администратора: Чат, Контакты, Агенты, Сотрудники, Порталы, База знаний, Настройки.
## AI
AI-first: новый диалог ведёт агент, отвечает по своим инструкциям и знаниям, не выдумывает фактов и передаёт диалог человеку по детерминированным правилам. После перехвата AI молчит, пока сотрудник явно не вернёт управление. AI работает только через провайдера организации (OpenRouter или любой OpenAI-совместимый endpoint); стоимость организация платит провайдеру напрямую. Для знакомства есть демо-провайдер без ключей и сети.
## Уведомления
О новых диалогах, запросах человека и ошибках подключений — в интерфейсе, звуком, письмом и через служебный Telegram- или MAX-бот.
## Что даёт продукт
Контакт-центр, который запускается за минуты: агенты принимают обращения из мессенджеров, почты и сайта, AI отвечает первым, сотрудники работают в едином чате, владелец видит всю картину — на своей инфраструктуре, без тарифов и внешней зависимости.
## Не входит
Тарифы, подписки, квоты, биллинг; managed AI и AI-кредиты; продажи, заказы, коммерческий каталог; лиды, сделки, воронки и канбан; настраиваемые наборы прав; командный центр; managed-облако как сервис; групповые звонки, SFU, screen sharing и запись медиа.
+2
View File
@@ -0,0 +1,2 @@
default_agent: codex
permission_mode: auto
+136
View File
@@ -0,0 +1,136 @@
# Разработка визуальной темы портала
Для разработчика, который добавляет новое оформление публичных страниц Help Center. Основание — ADR-0022, SPEC-0014.
Не входит: оформление интерфейса организации (персональная тема и акцент, `shared/appearance.ts`), вёрстка новых блоков портала, изменение состава страниц.
## 0. Что такое тема
Папка в репозитории, переопределяющая CSS-переменные публичной части портала. Без JS, без подмены компонентов; разметка и правила Help Center общие, тема меняет значения токенов и при необходимости добавляет правила в своём скоупе. Владелец выбирает тему: `Порталы → <портал> → Настройки → Оформление`. Портал хранит только id темы — изменений в БД, API и миграциях не нужно.
## 1. Где что лежит
```text
apps/internal-ui/src/features/help-center/
├── themes/
│ ├── README.md краткая памятка
│ ├── contract.css все токены --help-* и значения по умолчанию
│ ├── types.ts тип манифеста
│ ├── registry.ts автообнаружение тем, выбор темы и схемы
│ ├── usePortalTheme.ts применение темы к документу
│ ├── registry.test.ts тесты каталога = линтер правил тем
│ └── classic/ тема по умолчанию (шаблон)
│ ├── manifest.ts
│ └── theme.css
├── styles-layout.css вёрстка Help Center: только --help-*
├── styles-home.css
├── styles-article.css
└── styles-responsive.css
```
Регистрировать тему где-либо ещё не нужно — достаточно папки.
## 2. Быстрый старт
1. `cp -r apps/internal-ui/src/features/help-center/themes/classic apps/internal-ui/src/features/help-center/themes/aurora`
2. Заполнить `aurora/manifest.ts`; `id` равен имени папки.
3. Переопределить токены в `aurora/theme.css` под `html[data-portal-theme="aurora"]`.
4. `npm --workspace @chatballs/internal-ui run test`
5. Выбрать тему в настройках портала и посмотреть публичную страницу.
## 3. Манифест
```ts
import type { PortalThemeManifest } from "../types";
export const manifest: PortalThemeManifest = {
id: "aurora", // == имя папки, [a-z0-9-]
name: "Аврора", // видит владелец портала
description: "Светлое оформление с синим акцентом и крупными скруглениями.",
schemes: ["light", "dark"], // только реально проверенные схемы
preview: { bg: "#f7f8fc", ink: "#1a1c25", accent: "#4c5fd7" },
author: "Имя или команда", // необязательно
};
```
- `name` и `description` — пользовательский текст без служебной лексики; `description` показывается под заголовком «Оформление».
- Нет тёмных значений — оставить `["light"]`: тёмная схема не предлагается, сохранённая деградирует до светлой.
- `preview` — подложка, текст, акцент.
## 4. Файл стилей
```css
html[data-portal-theme="aurora"] {
--help-bg: #f7f8fc;
--help-surface: #ffffff;
--help-accent: #4c5fd7;
--help-accent-ink: #ffffff;
--help-radius-lg: 18px;
}
html[data-portal-theme="aurora"][data-theme="dark"] {
--help-bg: #12131a;
--help-surface: #191b23;
--help-accent: #7d8cf0;
}
html[data-portal-theme="aurora"] .help-category-icon {
box-shadow: 0 6px 18px rgba(76, 95, 215, 0.28);
}
@media (max-width: 560px) {
html[data-portal-theme="aurora"] .help-hero { margin-top: 24px; }
}
```
Правила скоупа: каждое правило начинается с `html[data-portal-theme="<id>"]`, включая внутри `@media` и `@supports`; `@keyframes` с префиксом id темы; `@font-face` допустим, файл шрифта в репозитории.
## 5. Справочник токенов
**Типографика:** `--help-font-body` (`Inter, "Segoe UI", Helvetica, Arial, sans-serif`; основной шрифт), `--help-font-heading` (`var(--help-font-body)`; логотип-название и заголовки), `--help-font-mono` (`var(--font-mono)`; код), `--help-heading-weight` (`650`).
**Поверхности:** `--help-bg` (`var(--surface-card)`; подложка, boot-загрузчик, «портал не найден»), `--help-surface` (`var(--surface-card)`; карточки разделов, панель поддержки, кнопки), `--help-surface-soft` (`var(--n-9)`; ховеры, шапки таблиц), `--help-surface-raised` (`var(--n-10)`; компактный поиск, шиммер), `--help-surface-strong` (`var(--n-7)`; ховер строк поддержки), `--help-skeleton` (`var(--n-8)`; плейсхолдеры статьи).
**Текст:** `--help-ink` (`var(--n-1)`; заголовки, названия, точки загрузчика), `--help-text` (`var(--n-2)`; иконка поиска, кнопки оценки), `--help-text-muted` (`var(--n-3)`; описания, breadcrumbs, содержание, подвал), `--help-text-subtle` (`var(--n-4)`; счётчики, дата, фокус поиска).
**Линии:** `--help-line` (`var(--n-6)`; разделители, рамки таблиц и кнопок), `--help-line-soft` (`var(--n-7)`; шапка, подвал, панель поддержки).
**Акцент:** `--help-accent` (`var(--n-1)`; плитка иконки раздела, плавающая кнопка поддержки), `--help-accent-hover` (`color-mix(in srgb, var(--help-accent) 85%, black)`), `--help-accent-ink` (`var(--surface-card)`; содержимое на акценте).
**Код:** `--help-code-bg` (`var(--n-1)`, в тёмной `var(--n-10)`), `--help-code-ink` (`var(--n-9)`, в тёмной `var(--n-1)`), `--help-code-inline-bg` (`var(--n-9)`).
**Форма и тень:** `--help-radius-xs` 5px, `-sm` 7px, `-md` 10px, `-lg` 12px, `-xl` 16px, `-2xl` 18px, `-pill` 999px; `--help-shadow-panel` (`var(--shadow-lg)`).
## 6. Тёмная схема
Схема выбирается владельцем портала (Светлая / Тёмная / Как в системе) и применяется `data-theme` на `<html>` — тем же механизмом, что у интерфейса; второй механизм вводить нельзя. Тёмные значения — под `html[data-portal-theme="<id>"][data-theme="dark"]`. Тема на значениях по умолчанию получает тёмную схему бесплатно; тема с фиксированными цветами обязана прописать тёмные значения полностью.
## 7. Проверка
```bash
npm --workspace @chatballs/internal-ui run test
npm --workspace @chatballs/internal-ui run typecheck
npm --workspace @chatballs/internal-ui run build
```
Сборка кладёт CSS темы в отдельный ленивый чанк `assets/theme-*.css`. `registry.test.ts` не пропустит: несовпадение id и папки, дубликаты; пустые `name`/`description`, пустой или неизвестный `schemes`; отсутствие `theme.css`; правило вне скоупа (в том числе в `@media`); `@keyframes` без префикса; токен вне контракта.
Ручная проверка: обе схемы, главная, статья, поиск, пустой результат, загрузка, мобильная ширина; консоль без ошибок и предупреждений.
## 8. Что теме нельзя
JS и подмена компонентов; глобальные правила; правка чужих файлов (`contract.css`, `styles-*.css`, другие темы — кроме §9); свои переменные вне контракта; внешние ресурсы (CDN); собственная тёмная тема через `@media (prefers-color-scheme)`.
## 9. Если рычага не хватает
Нет нужного токена — дефект контракта: добавить токен в `contract.css` со значением, равным текущему поведению; заменить литерал в `styles-*.css` на токен; проверить, что вёрстка не содержит `--n-*`, `--surface-*` и литеральных цветов; дополнить справочник и `themes/README.md`.
## 10. Жизненный цикл темы
Удалённая из сборки тема деградирует до `classic`, в настройках показывается недоступной. Переименование папки — новая тема; при необходимости — миграция данных по согласованию с владельцем. `classic` ничего не переопределяет; менять её вид — отдельное решение владельца.
## 11. Чек-лист перед коммитом
- id совпадает с папкой; `name` и `description` без служебной лексики.
- `schemes` проверены в браузере.
- Все правила скоупнуты, `@keyframes` с префиксом, только токены контракта.
- `test`, `typecheck`, `build` проходят.
- Публичная страница проверена в схемах, на десктопе и мобильной ширине; консоль чистая.
- Другие темы, контракт и вёрстка не тронуты (или §9 выполнен целиком).
+44
View File
@@ -0,0 +1,44 @@
# История релизов Chatballs
Сжатая история выпусков: что изменилось, миграции, особые шаги обновления и отката. Релизы публикуются на GitHub (`dartdavros/chatballs`); штатное обновление — из интерфейса или `docker compose pull && docker compose up -d --wait` с `compose.yaml` релиза. Поведение, введённое релизами, перенесено в соответствующие спецификации и решения.
| Версия | Дата | Главное | Миграции / особые шаги |
|---|---|---|---|
| 1.3.0 | 2026-09-12 | Несколько организаций: переключатель, приглашения существующих учётных записей, приглашение владельца письмом, администратор установки (`set_instance_admin`). Платформенный API создания организаций заработал в поставке. nginx разрешает upstream на каждом запросе. `backend-app` без паролей platform и migration; три тома секретов. Настройки установки переехали на `/api/v1/instance/…`; сессия отдаёт `isInstanceAdmin`; новые эндпоинты приглашений. | `tenancy.0031–0033`, `identity.0036–0037`. `secrets` сам переносит пароли в новые тома. Оркестраторам: `CHATBALLS_DB_PLATFORM_ALIAS=1` воркеру и новые пути файлов паролей. Проверка: `chatballs doctor`. |
| 1.3.1 | 2026-09-12 | «Ошибка загрузки» после мастера: промах по адресу установки перечитывает настройки сразу. Пустое состояние «Базы знаний» по центру. | — |
| 1.4.0 | 2026-09-13 | SVG-логотип организации с проверкой и CSP `sandbox`. Воркер: растущая пауза после сбоя опроса (6 с — 15 мин), журнал без шума. Демо-подключения не опрашиваются. | — |
| 1.5.0 | 2026-09-13 | Обновление из интерфейса: баннер, кнопка «Обновить», сервис `updater`, том `chatballs-updates`. | `updates.0001`, `tenancy.0034`. Ставится вручную (в прошлом `compose.yaml` нет `updater`). |
| 1.6.0 | 2026-09-13 | «Добавить организацию» в переключателе; выбор организации после входа; ссылки `/join` снова работают (поиск через каталог входа). | `tenancy.0035`: вставка организации ролью app только в своём контексте, id резервируется заранее. |
| 1.6.1 | 2026-09-13 | Проверка обновлений раз в 15 минут (`CHATBALLS_UPDATE_CHECK_INTERVAL_SECONDS`). | — |
| 1.6.2 | 2026-09-13 | Обновление из интерфейса действительно устанавливается (контейнер установки поднимался с входной точкой updater); оборвавшаяся установка не блокирует кнопку. | Версии 1.5.0–1.6.1 обновляются вручную один раз; зависший контейнер снять `docker rm -f chatballs-updater-apply`. |
| 1.7.2 | 2026-09-14 | Логотип в сайдбаре без обводки; крупнее загрузчики; загрузчик портала сразу по центру; «Работает на Chatballs» в футере порталов. | — |
| 1.7.4 | — | Импорт YAML создаёт недостающие категории; предпросмотр не блокирует документы; исправлено меню меток. | — |
| 1.8.0 | — | Coturn поднимается со стеком (3478, TURN-over-TLS выключен); адреса relay от адреса установки; «Завершить» из любой живой фазы. «Удалить диалог». Фото контактов хранятся в установке. Голосовое MAX без тела. Счётчик «Чат» по видимости. Категории сворачиваются; 20/50/100 на странице; окно выбора знаний по категориям и порталам. Перетаскиваемая кнопка «Начало работы». | `conversations/0025`. Открыть на файрволе `3478/udp`, `3478/tcp`, `49160–49999/udp`. |
| 1.8.1 | — | Аудиозвонок: окно оператора закрывается, когда клиент кладёт трубку. | — |
| 1.9.0 | — | Отдельная интеграция для расшифровки голосовых на агенте; ошибки расшифровки словами. | `ai/0020`. |
| 1.9.1 | — | User-Agent `Chatballs/<версия>` для запросов к провайдерам; проверка подключения объясняет отказ словами. | — |
| 1.10.0 | — | Две пары «провайдер + модель» на агенте; модель принадлежит агенту. | `ai/0021`, `ai/0022`. |
| 1.11.0 | — | Подключение сообщества ВКонтакте (Bots Long Poll); ссылки на справку в форме подключения; секреты из URL не попадают в журнал. | `integrations/0009`. |
| 1.12.0 | — | Открытие диалога гасит все его уведомления у этого сотрудника; марка ВКонтакте; точка режима в углу аватара. | — |
| 1.13.0 | — | Установка на нестандартном порту и за прокси панели (`X-Forwarded-Proto`); размер контекста агента 1–200. | `ai/0023`, `identity/0040`. Установкам на нестандартном порту — вписать `адрес:порт` в настройки один раз. |
| 1.14.0 | — | Ход AI вынесен в `worker-events` (реплики, очередь по диалогу, срок ответа); сбои провайдера по организации. Новый облик виджета: «печатает», разворот, анимация «джин», подпись «Работает на Chatballs». | `conversations/0026`, новый сервис `worker-events`. |
| 1.14.1 | — | Первое открытие виджета с анимацией и правильной кнопкой. | — |
| 1.15.0 | — | Раздел «Шаблоны ответов» в настройках; переменные в шаблонах. | — |
| 1.15.3 | — | Виджет на телефоне — на весь экран. | — |
| 1.16.0 | 2026-10-01 | Страница настройки веб-подключения: данные сайта, форма перед чатом, оформление и свои иконки/CSS. Данные сайта в контакте, событиях и промпте агента. Обновление сохраняет Compose override. Постоянные каталоги dev-данных вне worktree. | `webchat/0006`, `conversations/0027–0028`, `tenancy/0038–0039`. Установкам за прокси с override — первый переход вручную с обоими Compose-файлами. |
## Откат на 1.2.x (до разделения томов секретов)
Прежний релиз ждёт плоские файлы паролей в `chatballs-secrets`; без них его генератор создаст новые пароли. Перед запуском старой версии:
```bash
docker run --rm -v <проект>_chatballs-secrets:/s -v <проект>_chatballs-secrets-platform:/p:ro -v <проект>_chatballs-secrets-schema:/m:ro alpine sh -c 'cp /p/postgres_platform_password /m/postgres_migration_password /m/postgres_password /s/ && chmod 444 /s/postgres_*'
```
Миграции обратимы (`migrate tenancy 0030`, `migrate identity 0035`), но откат `identity.0037` удаляет должность, телефон и группы у ожидающих приглашений.
## Заметки для разработчиков из релизов
- Тесты бэкенда в dev-стеке — из контейнера `backend-admin`; изменение генератора секретов требует `docker compose -f compose.yaml -f compose.dev.yaml build secrets`.
- Playwright-проект `internal-ui` работает с русской локалью браузера; моки сессии отдают язык установки.
- RLS проверяется тестами под реальной ролью (`tenancy/test_rls.py`).
@@ -0,0 +1,13 @@
---
id: M01
title: Страница веб-подключения
order: 1
---
## Цель
Веб-подключение открывается отдельной страницей с субменю вместо модалки. На этой странице дальше появятся разделы «Данные с сайта», «Форма перед чатом» и «Оформление».
## Критерий готовности
Веб-подключение редактируется на странице /settings/integrations/{id} с разделами «Основные» и «Удалить подключение», код вставки есть в шапке; остальные провайдеры и создание работают через модалку, как раньше.
+13
View File
@@ -0,0 +1,13 @@
---
id: M02
title: Данные с сайта
order: 2
---
## Цель
Сайт передаёт поля о клиенте через Chatballs.setFields(), оператор видит их в карточке контакта в реальном времени, AI учитывает разрешённые поля (SPEC-0019, ADR-0030).
## Критерий готовности
Выполнены критерии приёмки SPEC-0019: 4 поля создаются в W1, setFields даёт значения у оператора, смена статуса обновляет бейдж без перезагрузки, пишет событие в ленту и учитывается AI; мусорные данные не сохраняются; изоляция организаций подтверждена тестом.
@@ -0,0 +1,13 @@
---
id: M03
title: Форма перед чатом
order: 3
---
## Цель
Администратор включает форму перед чатом, клиент видит её вместо экрана согласия с предзаполненными данными сайта (SPEC-0020).
## Критерий готовности
Выполнены критерии приёмки SPEC-0020: выключенная форма не меняет поведение, предзаполнение «с сайта», блокировка «Начать чат» без обязательных полей, значения попадают в контакт и данные сайта, смена текста согласия показывает форму снова.
@@ -0,0 +1,13 @@
---
id: M04
title: Оформление веб-виджета
order: 4
---
## Цель
Администратор меняет цвет, иконки, положение, размер и форму кнопки и задаёт свой CSS; изменения применяются на сайте без перевставки кода (SPEC-0021).
## Критерий готовности
Выполнены критерии приёмки SPEC-0021: цвет, позиция и форма применяются без перевставки кода, SVG очищается от скриптов, свой CSS действует только внутри окна чата, предупреждение о контрасте работает.
+49
View File
@@ -0,0 +1,49 @@
---
id: "0001"
title: Веб-чат
status: accepted
date: 2026-09-28
---
## Problem
Организации нужна постоянная точка входа на сайте и в порталах поддержки, работающая через общий контур коммуникаций наравне с мессенджерами и почтой: тот же контакт, идентичность, диалог, сообщение и inbox сотрудника. Модель виджета — SPEC-0002; основания — ADR-0018, ADR-0003, ADR-0004. Данные с сайта, форма перед чатом и оформление описаны в отдельных спецификациях: «Данные с сайта в веб-чате», «Форма перед чатом веб-виджета», «Оформление веб-виджета».
Отвечает за: запуск по публичному ключу, опубликованную конфигурацию, согласие, сообщения, вложения и голосовые, отображение состояния AI и сотрудника, приглашение на звонок и страницу звонка, восстановление истории, понятную недоступность.
## Scenarios
1. **Подключение к сайту**: `<script src="https://<адрес установки>/chat-widget.js" data-widget-key="<ключ>" async></script>` — ключ единственный параметр; агента и подключение backend разрешает сам.
2. **Первый визит**: launcher → приветствие и согласие (или форма перед чатом, если включена) → принятие → диалог с AI (история, свободный ввод, быстрые ответы, файл, голосовое, «печатает», явное обозначение виртуального помощника).
3. **Передача сотруднику**: сообщение «подключается человек, история ему доступна» → диалог с сотрудником в той же ленте.
4. **Звонок**: входящее приглашение → принять/отклонить → страница звонка → возврат к переписке.
5. **Недоступность**: понятное сообщение и резервные контакты, без ложного подтверждения отправки.
6. **Возврат**: переходы, перезагрузка, повторный визит восстанавливают текущий диалог.
## Requirements
- R-1. Фрагмент не содержит секретов; backend проверяет origin; конфигурация отдаётся только для опубликованного виджета и разрешённого origin; ошибка конфигурации не ломает страницу; сбой виджета изолирован от сайта.
- R-2. Публичная конфигурация содержит только публичные поля: `available`, `widgetKey`, `features`, `title`, `accent`, `greeting`, `consent {text, version}`, `quickReplies`, `fallback`, `fields` (схема своих полей без признака передачи AI), `preChat`, `appearance`. Неразрешённый origin получает отказ с причиной. Внутренние идентификаторы и технические флаги не отдаются.
- R-3. Резервные контакты собираются из других подключений того же агента.
- R-4. Запрещённые в подключении голосовые и звонки прячутся, а не показываются неработающими кнопками.
- R-5. До принятия согласия модель не вызывается и диалог не начинается.
- R-6. При первом открытии создаются контакт-гость, идентичность и непрозрачный высокоэнтропийный токен сессии (на сервере — hash). Токен живёт в контексте конкретного виджета и не принимается другим.
- R-7. Закрытие панели и уход со страницы не закрывают диалог; после закрытия диалога новое сообщение создаёт новый, связанный с предыдущим (ADR-0001).
- R-8. Текст, изображения, файлы, голосовые, быстрые ответы, системные события; видны автор, время, состояние отправки, ошибка; порядок устойчив к повторной доставке. Голосовое выглядит как в чате оператора: воспроизведение, волна, длительность.
- R-9. Тип и размер вложения проверяются до загрузки, есть прогресс и отмена; все клиентские ограничения дублируются сервером.
- R-10. Сообщение сохраняется сервером до подтверждения; launcher показывает непрочитанное; отсутствие свободного сотрудника не блокирует ответ AI.
- R-11. Передача бесшовна: тот же диалог, атомарная отмена генерации, AI не пишет после перехвата (ADR-0002).
- R-12. Защита: HTTPS, origin и список доменов, rate limit по сессии и адресу, ограничения длины и размера, защита от повторной и параллельной отправки и от перебора. Токен звонка проверяется до запроса камеры и не даёт доступа к переписке. Токены, SDP, ICE в журналы не пишутся.
- R-13. Payload модели проходит redaction; токен сессии и внутренние id модели не передаются.
- R-14. На десктопе — компактная панель поверх страницы с кнопкой разворота (окно почти вдвое шире); открытие и закрытие — анимация «джин» из кнопки, в том числе при первом открытии. На телефоне — окно на весь экран с учётом безопасных зон и клавиатуры, кнопка скрыта, закрытие кнопкой «—» в шапке, кнопки разворота нет.
- R-15. Шапка и launcher показывают знак агента или иконку, загруженную в оформлении виджета; над сообщением агента — имя и аватар; поле ввода без рамки: скрепка слева, запись голосового справа, при наборе — отправка. Внизу подпись «Работает на Chatballs».
- R-16. Доступность: клавиатура, видимый фокус, подписи, масштабирование; свободный ввод доступен всегда. Форма перед чатом необязательна и по умолчанию выключена; её включает администратор подключения.
- R-17. Удалённый владельцем диалог обнуляет переписку в виджете у клиента.
## Out of scope
Клиентская учётная запись, тикеты, отдельная очередь и модель диалога, оплата, хранение знаний, дерево сценариев; запись, групповые звонки, screen sharing; видеосообщения, реакции, стикеры; редактирование отправленного; преемственность анонимной истории между устройствами; продолжение диалога по почте; визуальный конструктор сценариев.
## Open questions
- Критерии приёмки: подключение одним фрагментом; неразрешённый домен не получает конфигурацию; модель не вызывается до согласия; история переживает переходы; закрытие панели не закрывает диалог; перехват без нового чата и двойного ответа; текст, изображение, файл и голосовое отправляются; при недоступности — резервные контакты; два виджета одного агента независимы; сообщения веб-чата видны в общем списке рядом с остальными каналами.
@@ -0,0 +1,42 @@
---
id: "0002"
title: Виджеты веб-чата как точки входа
status: accepted
date: 2026-09-28
---
## Problem
Организации нужно несколько точек входа веб-чата у одного агента — с разными доменами, оформлением и согласием, без неоднозначности входа и с достоверным источником обращения (ADR-0018). Поведение чата для клиента — SPEC-0001; данные с сайта, форма перед чатом и оформление — отдельные спецификации.
Термины: **виджет** — опубликованная точка входа; **ключ виджета** — публичный неизменяемый идентификатор, не credential; **веб-подключение** — транспортная привязка; **агент** — контекст обработки; **место установки** — использование виджета на странице.
## Scenarios
1. Администратор создаёт виджет, выбирая существующий совместимый агент (агент автоматически не создаётся), настраивает origins, оформление и согласие, публикует.
2. На двух сайтах стоят два виджета одного агента с разными ключами — обращения идут в одну очередь, но источник различается.
3. Администратор меняет оформление опубликованного виджета — фрагмент на сайте менять не нужно.
4. Виджет с историей отключается: новые сессии не принимаются, история остаётся.
5. Администратор открывает веб-подключение в настройках и попадает на его страницу с разделами «Основные», «Данные с сайта», «Форма перед чатом», «Оформление», «Удалить подключение».
## Requirements
- R-1. Модель `WebChatWidget`: `integration` (1:1 с WEB), `code`, `public_key`, `name`, `status` (`DRAFT | PUBLISHED | DISABLED`), `allowed_origins`, `presentation_config` (заголовок, приветствие, акцент, быстрые ответы, оформление, схема своих полей, форма перед чатом), `consent_config` (текст и версия), `anti_abuse_config`. Источник значений — `integration.config`, виджет получает их при сохранении подключения. Секретов и credentials сессий в записи нет.
- R-2. Виджет, подключение и агент — одной организации; подключение провайдера `WEB` привязано ровно к одному агенту и обслуживает один виджет; агент может иметь несколько виджетов; агент виджета разрешает анонимные сессии. Понятия «режим» нет.
- R-3. Виджет с историей не удаляется — отключается; публичный ключ не переиспользуется.
- R-4. Список виджетов: название и код, статус, агент, подключение, состояние origins, дата изменения.
- R-5. После появления трафика неизменяемы код, ключ, подключение и агент; название, статус, origins и конфигурации меняются с аудитом.
- R-6. Публикация отклоняется, если агент или подключение неактивны, production-origins пусты или некорректны, нет конфигурации согласия, либо ключ создаёт неоднозначность.
- R-7. Публичный вход: ключ → опубликованный виджет → origin → подключение и агент → выдача или восстановление анонимной сессии. Агент и технические параметры со страницы не принимаются. Пустой список origins допустим только в явно обозначенном контуре разработки.
- R-8. Каждый экземпляр виджета на странице изолирован: свой идентификатор, DOM и события. Страница показывает одну кнопку; её положение (слева или справа), размер и форму задаёт оформление виджета.
- R-9. Для нового диалога сохраняются подключение, виджет, агент, анонимная идентичность и минимальные метаданные места установки; токен сессии и чувствительные браузерные данные не сохраняются.
- R-10. Безопасность: точное сравнение origin без подстрок; rate limit по виджету, сессии и адресу; hash credential с ротацией и отзывом; токены не в URL и журналах; аудит публикации, отключения и изменений.
- R-11. Веб-подключение настраивается на странице `/settings/integrations/{id}` с субменю, как настройки портала: «Основные» (название, разрешённые домены, код вставки), «Данные с сайта», «Форма перед чатом», «Оформление», «Удалить подключение». В шапке страницы — код вставки с кнопкой «Копировать». Создание подключения и остальные провайдеры по-прежнему используют модалку.
## Out of scope
Выбор агента внутри виджета; авторизованный вход с проверенной личностью; одновременный показ нескольких кнопок.
## Open questions
- Критерии приёмки: два виджета одного агента с разными ключами, origins и оформлением работают независимо; одинаковые коды разных организаций не влияют на вход; credential одного виджета не принимается другим; агента нельзя подменить; отключённый виджет не принимает сессии; несовместимая публикация отклоняется; ответы не раскрывают секреты и технические коды; веб-подключение открывается страницей с разделами.
+40
View File
@@ -0,0 +1,40 @@
---
id: "0003"
title: P2P-онлайн-звонки
status: accepted
date: 2026-09-28
---
## Problem
Сотрудник должен запросить у клиента аудио- или видеозвонок из текущего диалога; разговор идёт один на один через собственный WebRTC-контур, жизненный цикл сохраняется в истории диалога (ADR-0010).
## Scenarios
1. Сотрудник открывает диалог и запрашивает звонок. Backend проверяет права, режим управления и отсутствие другого незавершённого звонка. Если диалогом управляет AI — атомарный перехват; при неуспешном claim звонок не создаётся и приглашение не уходит.
2. Создаются сессия и приглашение, в ленте — системное событие. Клиент веб-чата получает приглашение realtime-событием; в Telegram, MAX и ВКонтакте — кнопку-ссылку на защищённую страницу. Страница проверяет токен **до** запроса камеры и микрофона; недействительный токен — терминальное состояние без данных диалога.
3. После принятия обе стороны проходят соединение; любая сторона завершает звонок; в ленту — результат и длительность.
4. Клиент кладёт трубку — у оператора закрывается экран разговора и показывается итоговый экран с «Закрыть».
## Requirements
- R-1. Инициировать может сотрудник, которому диалог виден (SPEC-0004), без блокировки и при отсутствии другого незавершённого звонка. AI не создаёт, не принимает звонок и не получает медиа.
- R-2. Модель: `CallSession` (организация, диалог, инициатор, вид, статус, времена, кто завершил, причина ошибки, подключение доставки; длительность — от соединения до завершения), `CallInvite` (идентичность клиента, hash токена, срок, время открытия и ответа, статус доставки), `CallParticipant` (сторона, вход, выход, состояние соединения), технические метрики.
- R-3. Жизненный цикл `REQUESTED → RINGING → ACCEPTED → CONNECTING → ACTIVE → ENDED`; `DECLINED`, `CANCELLED`, `MISSED`, `EXPIRED` из ранних и `FAILED` из активных. Один незавершённый звонок на диалог; повторное завершение идемпотентно; истёкшее или отменённое приглашение принять нельзя; `ACTIVE` — только после подтверждённого соединения обеих сторон; обрыв — переподключение, после grace period — ошибка; закрытие вкладки — серверный таймаут; завершённый звонок не переоткрывается. «Завершить» у оператора заканчивает звонок из любой живой фазы.
- R-4. API: `POST /calls/conversations/{id}/`, `GET /calls/conversations/{id}/active/`, `GET /calls/{id}/`, `POST /calls/{id}/cancel/`, `POST /calls/{id}/access-token/`; публичные по токену: `POST /calls/invites/resolve/`, `GET /calls/access/state/`, `POST /calls/access/accept|decline|end/`.
- R-5. До соединения: предпросмотр камеры, состояния отсутствия разрешения или устройства, переключатели камеры и микрофона, действие и отказ, сообщение об истёкшем приглашении. Во время: удалённое и локальное видео, состояния камер и микрофонов, завершение, индикаторы соединения и переподключения, работа на мобильном. Отсутствие камеры — не ошибка.
- R-6. Signaling по WSS только для двух участников; события: приглашение, принятие, отклонение, отмена, завершение, offer, answer, ICE, состояние медиа и соединения. Ключ идемпотентности у каждой команды; поздние события завершённого звонка игнорируются; переподключение WS не создаёт звонок. Источник истины — PostgreSQL.
- R-7. Одно соединение; видео отключается без завершения; прямой маршрут предпочтителен, TURN — запасной. Backend не получает декодированное медиа и не хранит RTP, SDP и ICE.
- R-8. Coturn поднимается со стеком: порт 3478 TCP/UDP, relay-диапазон 49160–49999/udp, TURN-over-TLS по умолчанию выключен (включается отдельным compose-override), краткоживущие credentials от backend, запрет анонимного и внутрисетевого relay, healthcheck. Адреса relay и STUN вычисляются от адреса установки; вписанные вручную в «Настройки → TURN для звонков» побеждают.
- R-9. Только HTTPS и WSS; токен звонка короткоживущий, не даёт доступа к переписке и не попадает в аналитику, referrer и журналы; страница звонка задаёт ограничительную политику разрешений. Медиа не записывается.
- R-10. Системные события не дублируются при повторах и не меняют жизненный цикл диалога.
- R-11. Ошибки различаются: отказ в доступе к устройствам, отсутствие или занятость устройства, нет WebRTC, недоступен signaling, нет прямого маршрута при недоступном TURN, уход участника, переподключение, истёкшее приглашение. SDP, ICE, внутренние id и секреты пользователю не показываются.
- R-12. Приглашение на звонок по почте не отправляется.
## Out of scope
Запись аудио и видео, групповые звонки, SFU, screen sharing, телефония и SIP, звонки по инициативе клиента, участие AI в медиасессии.
## Open questions
- Критерии приёмки: запрос из интерфейса; перехват из режима AI, при конфликте приглашения нет; веб-чат — в сессии, мессенджеры — кнопка; истёкший и повторный токен не работают; устройства не запрашиваются до проверки токена; прямой маршрут и через Coturn; выключение камеры не завершает звонок; завершение идемпотентно; события без дублей и с длительностью; SDP, ICE и медиа отсутствуют в БД и журналах; второй звонок в диалоге создать нельзя; мобильный клиент проводит звонок целиком.
@@ -0,0 +1,40 @@
---
id: "0004"
title: Сотрудники, роли и группы
status: accepted
date: 2026-09-28
---
## Problem
Нужна модель сотрудника, ролей и групп, управление сотрудниками и передача владения в установке, где один человек может работать в нескольких организациях. Основания — ADR-0012, ADR-0021, ADR-0025.
## Scenarios
1. Администратор создаёт сотрудника с новым адресом — появляется учётная запись с паролем первичного доступа.
2. Администратор вводит адрес, принадлежащий учётной записи из другой организации, — уходит приглашение; в списке строка «Приглашён»; человек принимает его под своим входом.
3. Администратор блокирует сотрудника — доступ закрыт только к этой организации, сессии инвалидированы, открытые диалоги вернулись в очередь.
4. Владелец передаёт владение другому сотруднику.
## Requirements
- R-1. `HumanUser` — глобальная учётная запись (e-mail, аутентификация, имя, TOTP, глобальный статус). `OrganizationMembership`: user, organization, role (`OWNER | ADMIN | EMPLOYEE`), position_title, phone, totp_required, blocked_at, created_at. На уровне БД: `unique(user, organization)` и ровно один владелец в организации.
- R-2. Должность прав не выдаёт; роль не выводится из должности. Отдельных профилей доступа и наборов полномочий нет.
- R-3. OWNER — все права, не может быть удалён или заблокирован; ADMIN — те же права, включая управление администраторами; EMPLOYEE — только чат.
- R-4. Группа: имя (уникально в организации без учёта регистра), цвет, состав. Только граница видимости. Сотрудник может быть в нескольких группах; групп может не быть.
- R-5. Видимость: владелец и администратор — все диалоги; сотрудник — своих групп + без группы + где он ответственный.
- R-6. Создание: имя, e-mail, роль, должность, группы (телефон). Новый адрес — учётная запись с паролем первичного доступа; адрес активной учётной записи другой организации — приглашение (ADR-0025); адрес своей организации или деактивированной учётной записи — «занят». Владелец через общий поток не создаётся.
- R-7. Операции: просмотр списка, создание и изменение, сброс пароля и завершение сессий, блокировка, изменение администратора — OWNER и ADMIN; над владельцем — только передача владения; передать владение — только OWNER. Запреты проверяет backend, прямой идентификатор их не обходит.
- R-8. Сотрудник, участвовавший в диалогах, физически не удаляется — блокируется с сохранением ссылочной целостности.
- R-9. Передача владения в одной транзакции: блокировка записей участия, цель становится владельцем, прежний получает выбранную роль, ровно один владелец, событие аудита. Цель — активный сотрудник той же организации.
- R-10. Администратор не меняет чужой глобальный пароль, TOTP и глобальные сессии. Организация может требовать TOTP. Блокировка запрещает новые HTTP- и realtime-действия и инвалидирует сессии. В payload сотрудника нет паролей, TOTP-секретов и токенов.
- R-11. Аудит: создание, изменение данных и роли, блокировка и разблокировка, завершение сессий, сброс пароля, передача владения, отказ в привилегированном действии, изменения групп, приглашения — актор, цель, организация, действие, изменения, без секретов.
- R-12. Пока почта установки не настроена, письмо приглашения уходит в лог — это видно в «Платформе».
## Out of scope
Кадровый учёт, штатное расписание, отпуска, иерархия подчинённости, права по должности, несколько владельцев.
## Open questions
- Критерии приёмки: участие в нескольких организациях с разными ролями; блокировка не закрывает другие организации; второго владельца создать нельзя; действия над владельцем отклоняет backend; видимость соблюдается в HTTP, WebSocket и фоне; организация без групп работоспособна.
@@ -0,0 +1,44 @@
---
id: "0005"
title: Установка, развёртывание и жизненный цикл экземпляра
status: accepted
date: 2026-09-28
---
## Problem
Нужен единый технический и пользовательский контракт установки, развёртывания, первого запуска и обновления, одинаковый для установки пользователя, production владельца и staging (ADR-0011). Демо-данные — SPEC-0006; обновление из интерфейса — ADR-0027.
## Scenarios
1. `git clone … && ./scripts/start.sh` (Windows: `.\scripts\start.ps1`). Если рядом `release.env` — образы по digest, иначе сборка из исходников. Задавать нечего.
2. Человек открывает установку по IP (любой порт) по http и видит мастер первого запуска; после него — в приложении владельцем, мастер больше недоступен.
3. Установка переезжает на домен и https без правки конфигурации; за прокси панели работает при передаче `X-Forwarded-Proto`.
4. Обновление: резервная копия → образы целевого выпуска → `deploy` с миграциями → проверка здоровья.
## Requirements
- R-1. Хост: Linux x86_64 (первая цель — Debian 13) или Docker Desktop; Docker Engine и Compose plugin; открытые 80 и 443; для звонков — 3478 TCP/UDP и 49160–49999/udp. Не требуются nginx, Python, Node.js, Git, PostgreSQL, Redis, доступ к AI.
- R-2. Секреты: одноразовый `secrets` генерирует `secret_key`, `field_encryption_key`, `postgres_app_password`, `turn_secret` (том `chatballs-secrets`), `platform/postgres_platform_password` (`chatballs-secrets-platform`), `schema/postgres_migration_password`, `schema/postgres_password` (`chatballs-secrets-schema`). Идемпотентен, переносит файлы старых установок, выставляет права на каждом запуске. Переменная окружения выше файла.
- R-3. `release.env` — только версия и ссылки на образы по digest; `latest` не источник версии.
- R-4. Настройки площадки — в одной строке настроек установки, в «Настройки → Платформа»: публичный адрес (хост, схема, порт — запоминается при мастере), почтовый сервер (пока не задан — письма в лог), TURN-серверы и срок credentials, язык по умолчанию. Хранилище — «Хранилище файлов». Изменяет только администратор установки. Промах по адресу перечитывает настройки сразу (не чаще раза в секунду на процесс).
- R-5. Сервисы: `secrets`, `postgres` (pgvector/pgvector:pg16, без портов на хост), `redis` (без портов), `init`, `backend-app`, `backend-platform`, `backend-admin` (127.0.0.1:18001), `worker` (опрос, один экземпляр), `worker-events` (события и ход AI, реплики), `updater`, `frontend`, `gateway` (Caddy), `coturn`. Роли PostgreSQL: владелец кластера, `migration`, runtime `app` и `platform` без обхода RLS.
- R-6. Шлюз без зашитых хостов: `:80` отвечает по http на любом адресе без принудительного редиректа; https — для хостов, одобренных backend (домен установки и порталов), сертификаты по мере надобности; платформенная поверхность — на своём домене; admin и Coturn через шлюз не идут. Порт сохраняется в адресе для CSRF. По https — `__Host-`, `Secure`, HSTS.
- R-7. `init` только мигрирует; безопасен при повторе; статика собрана в образе.
- R-8. Мастер первого запуска: `organizationName`, `fullName`, `email`, `password`, `installDemo`. В одной транзакции: запомнить адрес; advisory-блокировка; проверить отсутствие организации; валидировать пароль против имени и e-mail; создать организацию (id резервируется заранее, запись ролью app в контексте новой организации — ADR-0026), системную категорию знаний, пользователя-владельца (с `is_instance_admin`) и membership; аудит; при выборе демо — поставить задачу, не ожидая её. После — мастер закрыт навсегда. Пароль владельца нигде, кроме формы, не фигурирует.
- R-9. CLI `./chatballs` без Python и Node: реализованы `doctor`, `deploy`, `status`, `logs`. Требования: fail-fast, ненулевой код при ошибке, без утечки секретов, блокировка параллельных операций, `--non-interactive`, идемпотентный `deploy`.
- R-10. Порядок развёртывания: проверить хост → образы → postgres и redis до здоровья → `init` → остальные сервисы → здоровье. Порядок не дублируется в CI, runbook'ах и ручных командах.
- R-11. Health: `/api/v1/health/live/`, `/api/v1/health/ready/`. Пока организации нет, установка технически здорова и показывает мастер.
- R-12. Обновление только вперёд; не меняет секреты и не переустанавливает демо. Откат после необратимой миграции — только восстановлением из копии.
- R-13. Резервная копия: дамп PostgreSQL, медиа, три тома секретов, метаданные выпуска; копия каталога работающего PostgreSQL не считается; Redis не входит. Восстановление — после проверки совместимости, с подтверждением и остановкой пишущих сервисов.
- R-14. Безопасность: секреты читаемы только нужным пользователям; CLI не принимает пароли аргументами; в журналы не попадают дампы, ключи и пароли; docker socket — только у `updater`; деструктивные команды требуют подтверждения, кроме авторизованного CI.
## Out of scope
Kubernetes и Helm, multi-node HA, managed-облако, автоустановка локальных моделей, лицензирование, телеметрия. Модель `InstallationState` и одноразовый setup-токен не реализуются: признак незавершённой установки — отсутствие организации.
## Open questions
- Команды CLI `install`, `update`, `backup`, `restore`, `rollback` не реализованы — T-001, T-002.
- В `README.md` и скриптах запуска встречается признак режима поставки `CLOUD` / `SELF_HOSTED`, в документации не описанный — T-003.
- Критерии приёмки: чистая машина поднимает продукт одной командой без `.env`; открывается по IP и показывает мастер; после мастера — владелец, мастер недоступен; повторный запуск не меняет пароли и данные; образ frontend работает на двух адресах; переход на домен и https без правки конфигурации; демо только по выбору и не переустанавливается.
@@ -0,0 +1,39 @@
---
id: "0006"
title: Демонстрационные данные
status: accepted
date: 2026-09-28
---
## Problem
Человек должен увидеть работающий контакт-центр сразу после установки, а не пустые экраны. Демо-набор — не системный seed, не production-данные и не тестовые фикстуры.
## Scenarios
1. В мастере первого запуска включён переключатель «Установить демо-данные» — установка ставится в очередь, воркер выполняет её, форма не ждёт.
2. В работающей системе «Настройки → Демо-данные» → установить.
3. Там же — удалить набор одной кнопкой; реальные данные и владелец не затронуты.
4. Разработка: `python manage.py seed_demo --organization <slug> --apply|--remove` (не входит в порядок развёртывания).
## Requirements
- R-1. Ставится только по явному выбору; не профиль Compose и не переменная; не переустанавливается при обновлении; не вызывает внешние сервисы; данные вымышленные.
- R-2. Содержание — вымышленное ателье «Норд», повторяющее кадры дизайн-baseline v2. Манифесты по доменам на языке организации в `apps/backend/chatballs/identity/demo_seed/data/<язык>/` (`organization.json`, `channels_ai.json`, `conversations.json`, `support.json`, `operations.json`), наборы ключей совпадают; бинарные вложения общие, текстовые документы — свои на каждый язык.
- R-3. Диалоги покрывают все состояния (ведёт AI, ждёт человека, ведёт сотрудник, закрыт, спам, архив); есть вложения, голосовые (Mozilla Common Voice, CC0), метки, приоритеты, заметки, шаблоны ответов (приветствие с переменной имени оператора); история AI за 30 дней. Набор покрывает каждую модель системы — проверяет тест сида.
- R-4. Ставится в организацию установщика; своей организации и владельца не создаёт.
- R-5. У демо-сотрудников общий публичный пароль из манифеста (`Chatballs-Demo-2026`) — осознанное отступление. Ограничения: не ставить на публичную боевую установку без понимания, что учётные записи активны; демо-сотрудники не получают роль владельца; удаляются вместе с набором.
- R-6. Состояние `DemoDataset` на организацию: status (`INSTALLING | INSTALLED | REMOVING | FAILED`), requested_by, records_count, error, started_at, finished_at. `FAILED` виден с причиной.
- R-7. Каждая созданная запись регистрируется в реестре (модель, PK, порядковый номер); удаление — только по реестру в обратном порядке. Запрещено удалять по совпадению имени или шаблона, очищать таблицы, сбрасывать последовательности, трогать владельца.
- R-8. Пока набор установлен, интерфейс показывает признак демо-данных.
- R-9. Даты — относительно момента установки (окно ~30 дней), абсолютных дат в манифестах нет.
- R-10. При установке и удалении запрещены письма, сообщения в мессенджеры, вебхуки, обращения к AI, живые задачи исходящей доставки, действующие credentials интеграций. Демо-агенты используют демо-провайдер. Демо-подключения воркер не опрашивает.
- R-11. Демо-данные входят в обычную резервную копию; реестр и состояние остаются согласованными после восстановления.
## Out of scope
Переключатель между организациями в демо; отдельная копия демо.
## Open questions
- Критерии приёмки: без выбора в мастере демо не ставится; `deploy` и обновление не создают его повторно; каждая запись в реестре; ни одного внешнего вызова; удаление снимает всё и сохраняет реальные данные и владельца; повторное удаление безопасно; тест покрытия моделей проходит.
@@ -0,0 +1,35 @@
---
id: "0007"
title: Интеграции AI-провайдеров
status: accepted
date: 2026-09-28
---
## Problem
Нужно зафиксировать поддерживаемые AI-провайдеры и правила их подключения при условии, что AI работает **только** через провайдера организации — без платформенных credentials, managed-режима и AI-кредитов (ADR-0008, ADR-0014, ADR-0020). Настройка поведения агента — SPEC-0008.
## Scenarios
1. Организация создаёт интеграцию OpenRouter, вводит ключ, выбирает модель из каталога, нажимает «Проверить».
2. Организация подключает свой OpenAI-совместимый endpoint (Custom): endpoint, ключ, модель текстом — и получает ответы агента без правки кода.
3. Сразу после установки агент отвечает через демо-провайдер без ключа.
## Requirements
- R-1. Провайдеры (род «LLM-провайдер», «Настройки → AI-провайдер»): **OpenRouter** — ключ организации, endpoint по умолчанию (редактируется), модель из каталога адаптера; **Custom** — ключ, произвольный endpoint, модель текстом, без каталога; **Демо** — без ключа, endpoint и модели.
- R-2. Демо-провайдер: без ключей и сети; детерминированно выбирает предложения знаний с наибольшим пересечением слов с вопросом; без основания или по просьбе человека — токен передачи оператору; собственные эмбеддинги малой размерности; явно помечен в интерфейсе как демонстрационный. Не production-режим.
- R-3. Контракт endpoint: OpenAI Chat Completions — `POST /chat/completions`, `Authorization: Bearer <key>`, `choices[0].message.content` и `usage`. OpenRouter и Custom используют один HTTP-слой. Исходящие запросы — User-Agent `Chatballs/<версия>`, собственный User-Agent конкретного запроса не перебивается.
- R-4. Провайдер разрешается через интеграцию агента. Вызов без агента завершается управляемой ошибкой, трактуемой как «эмбеддингов нет» — лексический поиск продолжает работать. Выбор первой интеграции и глобальная подмена запрещены. Тестовый адаптер — только в отладке и тестах.
- R-5. Секрет шифруется; интеграция хранит статус, время и текст последней ошибки и признак активности отдельно от проверки.
- R-6. «Проверить» делает реальный запрос и возвращает единообразный результат: успех или причину словами (названная провайдером причина — сразу; HTML-страница защиты — в одну строку; молчаливый 401/403 — совет проверить ключ и доступность провайдера из сети). Не выбрасывает исключение наружу, не раскрывает секрет; полный ответ — в журнал.
- R-7. Учёт каждого вызова: агент, модель, токены, стоимость, статус, использованные фрагменты. Для статистики, дневного лимита агента и расследования ответа. Тарификации нет.
- R-8. Отказ провайдера по существу запроса не повторяется; сбои считаются по каждой организации отдельно (ADR-0028).
## Out of scope
Настройка поведения агента; managed AI; тарификация.
## Open questions
- Критерии приёмки: Custom с endpoint, ключом и моделью даёт ответы; поле модели Custom рабочее; OpenRouter сохраняет каталог и проверку; демо отвечает без ключей и сети и помечен; вызов всегда через интеграцию агента; секрет не появляется в API, интерфейсе и журналах.
@@ -0,0 +1,37 @@
---
id: "0008"
title: "Агент: конфигурация и ход ответа"
status: accepted
date: 2026-09-28
---
## Problem
Агент — центральный объект: одновременно AI-собеседник и точка входа. Администратор должен настраивать его на одной карточке, а ответ клиенту — приходить вовремя и не блокировать другие каналы. Основания — ADR-0009, ADR-0029, ADR-0028, ADR-0002.
## Scenarios
1. Администратор создаёт агента мастером из одного шага; остальное настраивает в карточке.
2. В карточке выбирает провайдера и модель ответов, отдельно — провайдера и модель расшифровки голосовых, размер контекста, группу; видит подключения и код виджета.
3. Проверочный чат карточки отвечает с тем же окном истории, что и живой диалог.
4. Клиент пишет в мессенджер — приём записывает сообщение, ход AI считается отдельно; пока идёт ход, виджет показывает «печатает».
## Requirements
- R-1. Карточка агента — единственный центр администрирования: имя и статус, группа (необязательна), инструкции, модель, знания и статьи (только чтение состава — SPEC-0013), подключения со статусом, код вставки виджета, дневной лимит стоимости.
- R-2. Инструкции — три поля: Персонализация, Тон общения, Инструкции. Системный промпт: Персонализация → Тон → Инструкции → каталог материалов (знания со ссылками вложений и статьи с адресом Help Center); содержимое подтягивает retrieval. Системный промпт не переводится; язык ответа задаёт директива по полю `answer_language` (по умолчанию — как у клиента).
- R-3. Две пары «провайдер + модель»: ответы и расшифровка голосовых. Пустая модель — «как в интеграции». Модель расшифровки: своя → из интеграции расшифровки → `whisper-1`. Пустая интеграция расшифровки — «как у ответов».
- R-4. Размер контекста — число последних сообщений диалога для модели: 1–200, по умолчанию 20, в блоке «Модель». История выбирается в базе с ограничением.
- R-5. Изменения агента применяются сразу, без публикации.
- R-6. Ход AI выполняется в `worker-events`, ходы одного диалога — строго по очереди; ответ ограничен сроком — просрочка передаёт диалог оператору с понятным текстом клиенту.
- R-7. Ошибка расшифровки показывается оператору фразой «что случилось и где чинить», без ответа чужого API.
- R-8. Список агентов: имя, группа, статус, подключения, счётчик открытых диалогов. Количество агентов не ограничено.
- R-9. Мастер создания агента знания не выбирает.
## Out of scope
Версии и релизы конфигурации агента; выбор агента клиентом; автоматическое распределение диалогов по сотрудникам.
## Open questions
Нет.
+33
View File
@@ -0,0 +1,33 @@
---
id: "0009"
title: Email-подключение
status: accepted
date: 2026-09-28
---
## Problem
Клиентская переписка по почте должна становиться диалогами с агентом, передачей человеку и историей по контакту (ADR-0015).
## Scenarios
1. Администратор создаёт подключение «Email (IMAP/SMTP)», заполняет адрес, пароль приложения, IMAP и SMTP, выбирает агента и нажимает «Проверить».
2. Клиент пишет письмо — оно становится сообщением диалога агента; AI или сотрудник отвечает письмом в тот же тред.
3. Сотрудник читает HTML-письмо в изолированном контейнере; процитированная переписка свёрнута под «Показать предыдущие сообщения».
## Requirements
- R-1. Провайдер `EMAIL` рода «мессенджер»; ящиков несколько, каждый привязан к одному агенту. Назначение «уведомления» для email отклоняется валидацией.
- R-2. Секрет — пароль ящика (один для IMAP и SMTP), шифруется. Конфигурация: `email` (обяз.), `imapHost` (обяз.), `imapPort` (993), `imapSsl` (true), `smtpHost` (обяз.), `smtpPort` (465), `smtpSsl` (true).
- R-3. Форма: тип, название, адрес (моноширинным), пароль с подсказкой про пароль приложения, секции «Входящая почта · IMAP» и «Исходящая почта · SMTP», выбор агента. Поля LLM-провайдеров не показываются. Готовность: название, адрес, пароль (при создании), оба хоста.
- R-4. Приём: SSL по флагу, вход, INBOX; курсор `uidvalidity:last_uid`; первый запуск и смена `UIDVALIDITY` ставят курсор на текущий максимум без импорта истории; id события — `Message-ID`, запасной — `uidvalidity:uid`; идентичность — адрес отправителя в нижнем регистре, имя из `From`; текст — первая `text/plain`, иначе из HTML (обязателен); HTML дополнительно санитизируется структурным allowlist без стилей, форм, скриптов, активного контента, обработчиков и небезопасных ссылок; при вложениях — пометка в тексте; письма самого ящика пропускаются; ошибки соединения логируются и не прерывают воркер.
- R-5. Отправка: `From` — ящик, `To` — клиент, `Subject: Re: <тема>`, `In-Reply-To` и `References` на последнее входящее, `text/plain; charset=utf-8`, через outbox с ретраями, STARTTLS для порта 587. Диалог хранит тему и `Message-ID` последнего входящего. Приглашение на звонок по почте не отправляется.
- R-6. Проверка проходит только при успехе IMAP (соединение, вход, INBOX в режиме чтения) и SMTP (соединение, `EHLO`, вход); ошибка называет сторону.
## Out of scope
Почта установки (приглашения, сброс пароля); вложения входящих писем; OAuth2; исходящие диалоги, начатые сотрудником.
## Open questions
- Критерии приёмки: создание формой с секциями и проверка обеих сторон; входящее письмо — сообщение диалога агента ящика; ответ уходит в тред; повторная доставка не дублирует; новый ящик не втягивает историю; HTML показывается без скриптов и внешних ресурсов; Telegram, MAX, ВКонтакте и веб-виджет работают без изменений.
@@ -0,0 +1,37 @@
---
id: "0010"
title: Подключение сообщества ВКонтакте
status: accepted
date: 2026-09-28
---
## Problem
Клиенты пишут в сообщество ВКонтакте; эти обращения должны попадать к агенту так же, как из Telegram и MAX, в том числе на установке за NAT без публичного адреса (ADR-0008).
## Scenarios
1. Администратор создаёт подключение «ВКонтакте», вставляет ключ доступа сообщества, выбирает агента и нажимает «Проверить» — в карточке появляются название и адрес сообщества.
2. Клиент пишет в сообщество — отвечает агент; сотрудник может перехватить диалог и пригласить клиента на звонок кнопкой-ссылкой.
3. Ключу не хватает прав — проверка объясняет, какие права включить.
## Requirements
- R-1. Провайдер `VK` рода «мессенджер»; подключение принадлежит одному агенту.
- R-2. Приём — через Bots Long Poll: публичный адрес и доступ извне не нужны. Поддерживается прокси подключения.
- R-3. Идентификатор сообщества не вводится: его называет ключ; проверка кладёт название и адрес сообщества в карточку.
- R-4. Проверка смотрит настройки сообщества: без включённого Long Poll и события о входящем сообщении приём невозможен, и об этом говорится сразу. Настройки чужого сообщества система не меняет.
- R-5. Нехватка прав объясняется словами: ключу нужны права «Сообщения сообщества» и «Управление сообществом».
- R-6. От клиента принимаются текст, фотографии, документы и голосовые, с подстановкой имени, логина и фото профиля отправителя. Ответ — текстом и файлами; приглашение на звонок — кнопкой-ссылкой.
- R-7. Телефона и «поделиться контактом» нет — просьба о контакте уходит обычным сообщением. Голосовые оператор в этом канале не записывает (провайдер принимает только свой формат).
- R-8. Ключ передаётся строкой запроса, поэтому секретные параметры URL не попадают ни в журнал, ни в поле последней ошибки подключения.
- R-9. В ленте диалогов и в карточке контакта ВКонтакте показан своей маркой.
- R-10. Под выбором типа подключения — ссылка на статью Центра помощи про этот тип (как и для Telegram, MAX, почты и веб-виджета).
## Out of scope
Callback API (вебхуки) ВКонтакте; запись голосовых оператором; изменение настроек сообщества системой.
## Open questions
Нет.
@@ -0,0 +1,36 @@
---
id: "0011"
title: Библиотека знаний агентов
status: accepted
date: 2026-09-28
---
## Problem
Организации нужна общая библиотека материалов для агентов — с деревом категорий, вложениями, которые можно отдать клиенту, и мгновенным применением правок (ADR-0009, ADR-0007). Статьи портала в знания не превращаются (SPEC-0013).
## Scenarios
1. Администратор создаёт категорию и знание, прикладывает PDF — текст извлекается и индексируется.
2. Правит текст знания — фрагменты перестраиваются, агент сразу отвечает по-новому.
3. Агент отправляет клиенту в мессенджер ссылку на вложение — она открывается без сессии.
4. Массово перемещает материалы между категориями — состав знаний агентов не меняется.
## Requirements
- R-1. `KnowledgeCategory`: organization, parent (nullable), name, sort_order. Родитель той же организации; циклы запрещены; имя уникально среди детей; порядок — `sort_order`, затем имя; удаляется только пустая листовая; системная «Без категории» создаётся с организацией и не удаляется. Предела глубины нет.
- R-2. `Knowledge`: organization, category (обязательна), title, description (видно агенту в каталоге), content (Markdown), is_enabled. Версий, релизов и областей видимости нет. Категория — только размещение.
- R-3. `KnowledgeAttachment`: knowledge, public_id (непредсказуемый), file, original_name (уникально в знании), content_type, size, extracted_text. Ссылка скачивания абсолютна и строится от адреса установки; защита — непредсказуемость id.
- R-4. Агент использует: явно прикреплено ∩ `is_enabled` ∩ та же организация.
- R-5. Фрагменты и эмбеддинги перестраиваются при изменении содержимого или вложений; переиндексацию можно запустить явно.
- R-6. API: `GET/POST /ai/knowledge/`; `GET/PATCH/DELETE /ai/knowledge/{id}/`; `POST …/{id}/reindex/`; `POST …/{id}/attachments/`; `DELETE …/{id}/attachments/{id}/`; `POST /ai/knowledge/bulk/move/`; `POST /ai/knowledge/import/` (SPEC-0012); `GET/POST /ai/knowledge/categories/`; `GET/PATCH/DELETE /ai/knowledge/categories/{id}/`; `POST /ai/knowledge/bulk/agent/`; `POST /ai/agents/{id}/knowledge/select-category/`.
- R-7. Выбор категории целиком сохраняет конкретные идентификаторы; динамической подписки на категорию нет.
- R-8. Интерфейс: дерево категорий слева (сворачивается, свёрнутое запоминается), поиск и фильтры, таблица с серверной пагинацией (20/50/100 на странице), массовые действия. Управление категориями — создание, переименование, перенос, порядок, безопасное удаление — теми же общими компонентами, что и портал.
## Out of scope
Версии знаний; области видимости по подразделениям; импорт вложений через YAML.
## Open questions
- Критерии приёмки: непустая категория и «Без категории» не удаляются; перемещение не меняет состав агентов; выключенное знание не попадает в ответы, оставаясь прикреплённым; правка перестраивает фрагменты без публикации; ссылка на вложение работает из мессенджера; знание другой организации недоступно ни напрямую, ни массово.
@@ -0,0 +1,34 @@
---
id: "0012"
title: Импорт знаний из YAML
status: accepted
date: 2026-09-28
---
## Problem
Владельцу нужно массово загружать знания: собрать материалы → прогнать через ИИ вне продукта → получить YAML по контракту → загрузить в «Базе знаний». Инструкции агента — три поля карточки, отдельного импорта нет.
## Scenarios
1. Владелец выбирает YAML-файл, видит предпросмотр (включая сообщение о категориях, которые будут созданы) и импортирует.
2. Повторный импорт того же файла ничего не дублирует.
## Requirements
- R-1. YAML разбирается в браузере и отправляется JSON: `POST /ai/knowledge/import/`, тело `{ "documents": [ ... ] }`.
- R-2. Элемент: `title` (обязателен; ключ дедупликации — организация + заголовок), `description`, `categoryPath` (массив имён от корня), `content` (обязателен, Markdown; многострочное — блок-скаляр `|`). Областей видимости и кодов подразделений нет.
- R-3. Новое знание создаётся и индексируется сразу; существующее без изменений остаётся; изменённое содержимое или описание обновляется, переиндексация — только при изменении индексируемого содержимого.
- R-4. Категория меняется только при явном `categoryPath`; без пути новое знание попадает в «Без категории», существующее сохраняет категорию.
- R-5. Недостающие уровни `categoryPath` создаются автоматически; существующие с тем же именем и родителем используются повторно. Создание категорий и документа — одна транзакция: ошибка документа откатывает его категории, остальные продолжают.
- R-6. Пустой `title` попадает в ошибки, остальные документы импортируются. Предпросмотр не блокирует документы из-за отсутствующих категорий.
- R-7. Ответ: `{ "created", "updated", "unchanged", "failed": [{"title", "detail"}] }`.
- R-8. Импорт не меняет состав знаний агентов — прикрепление явное (SPEC-0013).
## Out of scope
CSV и другие форматы; генеративная часть; импорт вложений; management-команда произвольного импорта (предустановленные материалы — только в демо-наборе).
## Open questions
Нет.
@@ -0,0 +1,35 @@
---
id: "0013"
title: Прикрепление источников знаний к агенту
status: accepted
date: 2026-09-28
---
## Problem
Составом знаний агента нужно управлять одним способом — массово из библиотек, — включая статьи портала без копирования (ADR-0017). Модели библиотеки (SPEC-0011) и портала (SPEC-0014) не меняются.
## Scenarios
1. В библиотеке знаний администратор выделяет материалы → «Прикрепить к агенту» → выбирает агента и режим (прикрепить/открепить) → видит, сколько изменено и сколько пропущено.
2. То же из материалов портала для статей.
3. Статью архивируют — она исчезает из ответов, но остаётся прикреплённой; восстановление возвращает её без повторного прикрепления.
## Requirements
- R-1. Агент связан со знаниями и статьями двумя M2M; уникальность — пара «агент и элемент»; обе стороны одной организации.
- R-2. Фрагмент имеет ровно один источник (CHECK), уникальность — «источник и номер чанка».
- R-3. Фрагменты статьи строятся при публикации редакции и снимаются при архивации; черновик не индексируется никогда.
- R-4. Статья доступна агенту, если совпадает организация (проверка при прикреплении), статья опубликована и её портал не архивирован (проверка в runtime).
- R-5. Поиск — по фрагментам обоих источников одним запросом; каталог промпта — две секции (знания со ссылками вложений, статьи с адресом Help Center); цитата использует заголовок своего источника.
- R-6. API: `POST /ai/knowledge/bulk/agent/` и `POST /ai/portal-articles/bulk/agent/`; тело — агент, `knowledgeIds` либо `articleIds`, действие. Ответ — агент, действие, признак изменения, изменённые и пропущенные id, полный состав после операции.
- R-7. Неизвестный или чужой id отклоняет запрос целиком; неизвестный агент — `404`; прикрепление пропускает недоступные и возвращает их списком, уже прикреплённые не считаются изменением; открепление доступность не проверяет; операция атомарна и блокирует агента; аудит с фактически изменёнными id.
- R-8. Вкладка «Знания» в карточке агента — только чтение: две секции, элемент ведёт в свою карточку (статья — в публичный Help Center), выключенное знание и неопубликованная статья помечены статусом. Окно выбора знаний разложено по категориям и порталам, со счётчиками и «Выбрать все», материалы грузятся целиком.
## Out of scope
Динамическая подписка агента на категорию; зеркалирование статей в знания.
## Open questions
- Критерии приёмки: недоступный элемент не меняет состав и возвращается в пропущенных; повтор не создаёт дубль; открепление снимает связь независимо от доступности; публикация создаёт фрагменты, архивация удаляет; черновик никогда не в поиске; поиск возвращает оба источника; архивная статья исчезает из ответов, оставаясь прикреплённой; во вкладке агента нет операций изменения.
@@ -0,0 +1,40 @@
---
id: "0014"
title: Порталы поддержки и публичная база знаний
status: accepted
date: 2026-09-28
---
## Problem
Организации нужны публичные Help Center с деревом разделов, статьями, редакциями, своим адресом, темой и встроенным веб-чатом, а внутреннее управление — в той же композиции, что и библиотека знаний (ADR-0016, ADR-0022).
## Scenarios
1. Администратор создаёт несколько порталов (черновик не блокирует следующий), наполняет разделы и статьи, публикует выбранную редакцию.
2. Подключает свой домен — система проверяет, что он ведёт на сервер, и выпускает сертификат.
3. В настройках портала выбирает тему и схему — публичная страница меняет оформление без вспышки.
4. Клиент на портале читает статью, оценивает её и пишет в веб-чат.
5. Архивирует портал с подтверждением — портал только для чтения, доступно восстановление.
## Requirements
- R-1. Раздел «Порталы»: список и карточка; действие создания видно всегда. Карточка — единая библиотека материалов без вкладок; настройки — отдельным окном. Ошибки публикации, архива и сохранения — в контексте действия, без технического текста.
- R-2. Управляющее API: `/support/portals/` (GET, POST), `/{id}/` (GET, PATCH, DELETE), `/{id}/status/`, `/{id}/widgets/` (только анонимные виджеты), `/{id}/domain/` (GET, PUT), `/{id}/domain/verify/`, `/{id}/categories/…`, `/{id}/articles/…`, `/{id}/articles/import/`, `/{id}/articles/{id}/publish/`, `/archive/`, `/revisions/`.
- R-3. Композиция материалов: дерево разделов со счётчиками слева; поиск, фильтры языка и статуса, единая таблица справа; тот же паттерн управления разделами и редактора; видимая история неизменяемых редакций с отметкой опубликованной и выбором редакции при публикации; общие состояния загрузки, пустоты, ошибки, только-чтения и архива. Компоненты — из общего модуля. Фильтр родителя включает потомков, счётчик — всё поддерево.
- R-4. Публичная поверхность: `GET /api/v1/help/`, `GET /api/v1/help/articles/`, `GET /api/v1/help/articles/{slug}/`, `POST /api/v1/help/articles/{slug}/feedback/`, `GET /api/v1/help/files/{public_id}/`. Портал — по HTTP Host до разбора маршрута; чтение в контексте организации с проверкой публикации; неизвестный путь — публичный `404`; переход на форму входа запрещён.
- R-5. Список статей — без полного текста; счётчики вычисляет backend; список ограничен. Отзыв защищён rate limit и не растёт неограниченно.
- R-6. Markdown — безопасный стандартный парсер с GFM (якоря, ссылки, изображения, вложенные списки, цитаты, код, таблицы, задачи); пользовательский HTML не исполняется.
- R-7. Адрес: ключ портала (`[a-z0-9-]`) + базовый help-домен, интерфейс показывает URL целиком. Свой домен нормализуется, не совпадает с доменом приложения и адресами порталов, уникален; техническая проверка без подтверждения владения; до активации работает адрес по умолчанию.
- R-8. Веб-чат портала — конкретный опубликованный виджет, разрешающий origin портала и ведущий к активному агенту; только штатный лоадер.
- R-9. Темы: вёрстка читает только `--help-*`; правила темы скоупятся; тема без JS; внешние ресурсы не грузятся; портал хранит id темы, схему и параметры; отсутствующая тема деградирует до `classic` и показывается недоступной; схема через `data-theme`; тема применяется до первого кадра (загрузчик по центру сразу, стиль в общем CSS).
- R-10. В футере порталов — «Работает на Chatballs» со ссылкой.
- R-11. Лимитов и тарифных условий нет; скрытое отключение кнопки создания запрещено.
## Out of scope
Продукты на портале и авторизованная поддержка из продукта (ADR-0023); подтверждение владения доменом.
## Open questions
- Критерии приёмки: несколько порталов со своими адресами; адрес портала не показывает вход, адрес приложения — Help Center; своим доменом нельзя занять чужой; иерархия и счётчики поддерева работают; стандартный Markdown без исполнения HTML; смена темы без вспышки, удаление темы не ломает данные; архивный портал — только чтение; нет второго стандарта таблиц, статусов, меню, форм и редактора.
@@ -0,0 +1,47 @@
---
id: "0015"
title: Интерфейс организации
status: accepted
date: 2026-09-28
---
## Problem
Рабочее пространство организации (`apps/internal-ui`) должно строиться вокруг чата, быть минимальным по сущностям, информативным плотностью, быстрым для входа и с одним стандартом на каждый элемент. Визуальный стиль (палитра, графика, шрифты) этой спецификацией не определяется — его решает владелец; здесь структура, поведение и механизм темизации. Основания — ADR-0019, ADR-0021.
## Scenarios
1. Владелец после мастера видит чек-лист «Запуск» из трёх шагов и проходит его без документации меньше чем за пять минут до тестового сообщения агенту.
2. Сотрудник входит — сразу открывается чат; навигации нет, только логотип, дерево диалогов и меню профиля.
3. Пользователь нескольких организаций после входа выбирает, с какой начать; переключает организации у логотипа; при праве — «Добавить организацию».
4. Пользователь меняет тему и акцент в профиле — на всех устройствах и без перезагрузки.
## Requirements
- R-1. Принципы: чат — главный экран; нет «каналов», «подключений», «отделов», «релизов»; состояние видно без кликов, декоративные плитки запрещены; один паттерн на задачу.
- R-2. Роли в интерфейсе не различаются ничем, кроме бейджа роли и отсутствия «удалить»/«заблокировать» на карточке владельца (скрыты, не задизейблены).
- R-3. Навигация владельца и администратора — семь плоских пунктов: Чат (бейдж очереди по границе видимости, без удалённых диалогов), Контакты, Агенты, Сотрудники, Порталы, База знаний, Настройки. Прямые URL для каждого раздела; deep-link ведёт в диалог. Нет командного центра, «Отделов», «Каналов», «Интеграций» и «Использования AI» первого уровня, «Продаж» и «Продуктов».
- R-4. Чек-лист «Запуск»: создать агента, подключить точку входа, пригласить сотрудников; каждый шаг — одна кнопка-переход; отмечается автоматически по данным; исчезает после выполнения или «Скрыть». Кнопка «Начало работы» перетаскивается, место запоминается.
- R-5. Пустые экраны отвечают на «что это, зачем, что нажать» короткой фразой и одной кнопкой, видимой только тому, кто может выполнить действие.
- R-6. Подсказки — единый компонент, только владельцу и администратору; не блокируют, закрываются одним кликом, не более одной на экране, без туров; реестр в коде единым списком; факт закрытия — в браузере.
- R-7. Темизация: цвета только через CSS-токены и тему antd; светлая и тёмная обязательны, переключатель Светлая / Тёмная / Как в системе (по умолчанию — система) через `data-theme`; настройка хранится на сервере. Акцент по умолчанию `#1677ff`, выбирается из пресетов или произвольно; производные оттенки и контраст вычисляются автоматически; статусные цвета от акцента не зависят. Цвет подключения живёт только в его бейдже (у каждого канала — своя марка). Плотность compact enterprise.
- R-8. Чат: три колонки — список с фильтрами (группа, агент, состояние, «ждут человека», вкладки «Общая очередь» и «На мне») · лента · контекст-панель (контакт и история). Действия над диалогом — перехват, возврат AI, перенос в группу, ответственный, закрытие, метки — в одной строке над лентой; «Удалить диалог» — только владельцу и администратору (SPEC-0018). Шаблоны ответов — через «/» или кнопку «Шаблоны».
- R-9. Контакты: таблица (контакт, подключения, последний диалог, открытые); карточка — профиль, идентификаторы, диалоги, аудит; без «лид/клиент». Фото контактов из мессенджеров хранятся в установке.
- R-10. Агенты: SPEC-0008.
- R-11. Сотрудники: таблица (человек, роль, группы, статус) включая строки «Приглашён»; приглашение — модальное окно; группы управляются здесь же.
- R-12. Порталы и База знаний — одна композиция библиотеки из общего модуля.
- R-13. Настройки — субменю слева, один раздел на экране: Организация (название, логотип PNG/JPEG/WebP/SVG, часовой пояс, валюта, язык), Группы, AI-провайдер, Интеграции, Шаблоны ответов (SPEC-0016), Голосовые и звонки / TURN для звонков, Хранилище файлов (только администратор установки), Платформа (адрес установки, почта, обновления; только администратор установки), Демо-данные. Профиль — отдельная страница из меню пользователя.
- R-14. SVG-логотип проверяется при загрузке (скрипты, обработчики, `foreignObject`, внешние ссылки в `href` и `url()`, DOCTYPE и сущности отклоняются с понятной ошибкой) и отдаётся с CSP `sandbox` и `nosniff`. В сайдбаре — без обводки и подложки.
- R-15. Списки (порталы, статьи, контакты, сотрудники, агенты, аудит) — страницами: `items`, `page`, `pageSize`, `total`, `pageCount`; поиск, фильтры и сортировка — параметры запроса; смена фильтра — на первую страницу; страница за пределами — последняя существующая; один подвал на всё приложение.
- R-16. Ленты (список диалогов, сообщения) — окном по курсору: `items`, `hasMore`, `cursor`. Открытие диалога — хвост 50 сообщений; прокрутка вверх догружает с сохранением места; обновление — только новое; карточка диалога сообщений не несёт; список обновляет голову без сброса прокрутки.
- R-17. Выпадающие выборы (ответственный, передача владения, фильтр агентов) — справочники (id, имя, аватар) с серверным отбором и поиском.
- R-18. Оповещения — WebSocket-канал организации сообщает, что изменилось; данные забираются обычным HTTP с проверкой видимости; событие инбокса без идентификаторов; подписка на открытый диалог — с проверкой видимости; опрос — запасной путь.
- R-19. Целевая ширина 1280–1600, минимум 1024 (контекст-панель складывается); мобильная версия — только чат. Фокус-стили, клавиатура в чате, контраст AA в обеих темах при любом акценте.
## Out of scope
Визуальный стиль; адаптация админ-разделов под телефон; командный центр; экран управления администраторами установки.
## Open questions
- Критерии приёмки: от первого входа до тестового сообщения ≤ 5 минут; семь пунктов навигации, у сотрудника навигации нет; ни одного сырого hex вне токенов и второго варианта таба, меню, ссылки; обе темы на всех экранах, смена без перезагрузки, настройка переживает перелогин и устройство; права владельца и администратора совпадают кроме удаления и блокировки владельца; ни один список не запрашивается целиком.
+34
View File
@@ -0,0 +1,34 @@
---
id: "0016"
title: Шаблоны ответов
status: accepted
date: 2026-09-28
---
## Problem
Операторам нужны готовые ответы, а администраторам — место, где их заводить и править. Раньше шаблоны можно было только вставить в диалог. Готовый текст не должен уйти клиенту с пустой подстановкой вроде «Здравствуйте, ,».
## Scenarios
1. Администратор в «Настройки → Шаблоны ответов» добавляет шаблон, вставляет переменную «имя клиента» кнопкой «Вставить переменную».
2. Оператор в диалоге набирает «/» или нажимает «Шаблоны», выбирает шаблон — переменные сразу заменены значениями, текст можно поправить.
3. Гость на сайте не представился — переменная остаётся в тексте, над полем «Заполните в тексте: имя клиента», отправка заблокирована до замены.
## Requirements
- R-1. Шаблоны общие на организацию: правят те, кому доступны настройки; пользуются все операторы.
- R-2. Список: название, начало текста, дата изменения; «Добавить шаблон»; правка по щелчку на строке; удаление с подтверждением.
- R-3. Название уникально в организации; занятое название — понятная ошибка.
- R-4. Переменные: имя клиента, имя оператора, название организации; вставляются в позицию курсора.
- R-5. При выборе шаблона переменные заменяются настоящими значениями; незаполненная переменная остаётся, показывается подсказка, и сообщение не отправляется, пока она не заменена.
- R-6. Шаблон с неизвестной или опечатанной переменной не сохраняется — сервер называет эту переменную.
- R-7. Шаблоны, заведённые до появления переменных, работают как есть.
## Out of scope
Личные шаблоны оператора; шаблоны, привязанные к агенту или группе.
## Open questions
Нет.
@@ -0,0 +1,35 @@
---
id: "0017"
title: "Уведомления сотрудников: доставка и прочтение"
status: accepted
date: 2026-09-28
---
## Problem
Рабочие события (клиент ждёт, новое сообщение, ошибка подключения) должны доходить до сотрудников, а счётчик непрочитанного — отражать реальность и не звать туда, где человек уже был (ADR-0006).
## Scenarios
1. Клиент запросил оператора — сотрудники, которым виден диалог, получают уведомление в интерфейсе, звуком, в браузере, письмом и в привязанный служебный бот.
2. Сотрудник открывает диалог — все уведомления по нему у этого сотрудника гаснут, счётчик сходится во всех его вкладках.
3. Сотрудник привязывает служебный Telegram-бот: получает в профиле одноразовый код и открывает бота по deep-link.
## Requirements
- R-1. Способы: центр уведомлений, звук в чате, browser notifications, письмо через почту установки, служебный Telegram- или MAX-бот (отдельная интеграция с назначением «уведомления», не привязана к агенту).
- R-2. Типы — реестр: базовые `DIALOG_WAITING`, `DIALOG_NEW_MESSAGE`, `INTEGRATION_ERROR`; в интерфейсе также «новый диалог» и «долго ждёт». Адресация — всем, владельцу, работающим с диалогами или конкретному пользователю; диплинк на объект. Уведомления о диалогах — по границе видимости.
- R-3. Открытие диалога гасит **все** уведомления о нём (новый диалог, запрос оператора, новое сообщение, долго ждёт), включая не поместившиеся в шторку (в неё грузятся последние 50).
- R-4. Уведомления гасятся только у того, кто открыл диалог; у других сотрудников остаются.
- R-5. Счётчик непрочитанных сходится во всех открытых вкладках сотрудника сразу.
- R-6. Старый адрес уведомления распознаётся; нечисловой идентификатор отклоняется.
- R-7. Каждая доставка идемпотентна; недоступность одного способа не блокирует остальные. Сотрудник настраивает способы и типы в пределах роли.
- R-8. Уведомления удалённого диалога удаляются вместе с ним.
## Out of scope
Использование клиентских ботов организации как служебного канала; email-подключение как канал уведомлений.
## Open questions
Нет.
@@ -0,0 +1,36 @@
---
id: "0018"
title: "Диалоги: управление, метки и удаление"
status: accepted
date: 2026-09-28
---
## Problem
Сотрудникам нужно управлять диалогом в одном месте — перехват, возврат AI, группа, ответственный, метки, закрытие, — а владельцу — полностью удалять диалог. Основания — ADR-0001, ADR-0002, ADR-0021.
## Scenarios
1. Сотрудник в «Общей очереди» забирает диалог (claim) — он переходит во вкладку «На мне», AI замолкает.
2. Сотрудник переносит диалог в другую группу и назначает ответственного.
3. Сотрудник добавляет метку: вводит название и нажимает «+» или Enter.
4. Владелец удаляет диалог — исчезают переписка, вложения, звонки и уведомления.
## Requirements
- R-1. Действия над диалогом — в одной строке над лентой: перехват, возврат AI, перенос в группу (или из группы), назначение ответственного, закрытие, метки.
- R-2. Claim атомарен; писать в режиме `HUMAN` может назначенный сотрудник; владелец и администратор перехватывают и переназначают; возврат к AI — только явным действием.
- R-3. Перенос и смена ответственного доступны владельцу, администратору и сотрудникам, видящим диалог; назначение ответственным делает диалог видимым сотруднику.
- R-4. Меню меток: поле названия не сжимается, без полосы прокрутки, новая метка — «+» или Enter; ошибка загрузки меток показывается.
- R-5. «Удалить диалог» удаляет переписку, вложения, звонки и уведомления; право — только у владельца и администратора; писать в удалённый диалог нельзя; переписка в виджете у клиента обнуляется.
- R-6. Точка режима диалога стоит в углу аватара в любой строке списка, независимо от меток и таймера ожидания.
- R-7. Фото контактов из Telegram, MAX и ВКонтакте скачиваются и хранятся в установке, а не подтягиваются ссылкой с чужого домена.
- R-8. Голосовое из MAX без тела в событии забирается отдельным запросом.
## Out of scope
Автоматическое распределение диалогов; повторное открытие закрытого диалога.
## Open questions
Нет.
@@ -0,0 +1,61 @@
---
id: "0019"
title: Данные с сайта в веб-чате
status: accepted
date: 2026-09-29
---
## Problem
Сайт уже знает о посетителе много полезного: кто он, есть ли у него активный заказ, какой у заказа номер и статус. Сейчас веб-чат начинается с анонимного гостя, поэтому оператор и AI-агент переспрашивают то, что сайт мог бы передать сам. Статус заказа меняется по ходу диалога, и об этом нужно узнавать без перезагрузки.
Основание — ADR «Данные с сайта — недоверенные поля контакта в контексте агента». Утверждённый макет: `design/baseline/Веб-чат · поля и оформление/` (кадры W1, C1, M2; пересказ — README.md рядом). Кейс макета — столовая «Обед».
## Scenarios
1. Администратор открывает веб-подключение → раздел «Данные с сайта» (W1), видит таблицу «Поля контакта» (Имя, Email, Телефон — только чтение) и добавляет свои поля: «ID клиента» (строка), «Активный заказ доставки» (да/нет), «Статус заказа» (список: Принят, Готовится, В пути, Доставлен, Отменён — с цветами), «Номер заказа» (строка). Для трёх последних включает «Видит AI». Копирует пример кода.
2. Сайт вызывает `Chatballs.setFields({ name, email, user_id, has_active_order: true, order_status: "cooking", order_number: "10482" })`, даже до загрузки виджета. Клиент открывает чат и пишет.
3. Оператор открывает диалог (C1): во вкладке «Контакт» появилась секция «Данные с сайта» с замком и отметкой «обновлено в HH:MM».
4. Сайт вызывает `Chatballs.setFields({ order_status: "on_the_way" })`. У оператора без перезагрузки меняется бейдж и подсвечивается строка, в ленте появляется событие «Сайт обновил данные: статус заказа «Готовится» → «В пути»». AI в следующем ответе говорит, что заказ в пути (M2).
5. Сайт присылает неизвестный ключ или строку в поле «да/нет»: виджет работает, лишнее не сохраняется.
## Requirements
### Схема полей
- R-1. Схема хранится в `integration.config.fields` WEB-подключения. Поле: `key` (`^[a-z][a-z0-9_]{0,39}$`, уникален в подключении, после создания не меняется), `label` (до 60 символов), `type` (`string | number | boolean | datetime | enum | email | phone | url`), `options` (только для enum: `value`, `label`, `color`), `aiVisible`, `order`.
- R-2. Не больше 30 своих полей на подключение. Ключи `name`, `email`, `phone` зарезервированы и своими быть не могут. Сервер отклоняет нарушения с понятной ошибкой.
- R-3. Раздел «Данные с сайта» (W1): таблица «Поля контакта» только для чтения; таблица «Свои поля» с перетаскиванием для порядка, выбором типа, чипами значений списка `label · value` и «+ значение», тумблером «Видит AI» и меню ⋯ (переименовать, удалить). Там же блок с примером кода передачи. Всё — ровно как в макете.
- R-4. Удаление поля не удаляет сохранённые значения, но они перестают показываться оператору и передаваться AI.
### Передача с сайта
- R-5. Лоадер `chat-widget.js` объявляет `window.Chatballs` с очередью: `setFields`, вызванный до загрузки лоадера, не теряется. Существующий `window.ChatballsChat` продолжает работать.
- R-6. `setFields` — частичное обновление (слияние по ключам); `null` очищает значение. Вызовы склеиваются, данные отправляются не чаще раза в 500 мс.
- R-7. Лоадер передаёт поля в iframe через `postMessage({ type: "chatballs-set-fields", fields })` только своему окну. До начала сессии iframe держит поля в памяти и отправляет их вместе со стартом сессии, после — `POST /api/v1/webchat/fields/` с токеном сессии.
- R-8. Публичная конфигурация виджета отдаёт схему полей без признака `aiVisible`.
### Сервер
- R-9. Валидация по схеме: неизвестные ключи и значения неверного типа отбрасываются, для сайта это не ошибка, в журнал — предупреждение без значения. Для enum принимаются только `value` из `options`, строки — до 500 символов.
- R-10. `name`, `email`, `phone` записываются в контакт, только если там пусто или прошлое значение пришло с этого же подключения. Ручную правку оператора сайт не затирает.
- R-11. Остальные значения хранятся в `contact_field_values` (организация, контакт, подключение, ключ, значение, время изменения), последнее значение на ключ. Таблица под RLS, есть тест изоляции.
- R-12. Если значение изменилось и у контакта есть открытый диалог, в ленту пишется системное событие с кодом и параметрами (подпись, старое и новое отображаемое значение). Фразу собирает бэкенд на языке читателя. Событие пишется только для полей типа enum и boolean, чтобы не засорять ленту.
- R-13. Оператору уходит событие по WebSocket: карточка и лента обновляются без перезагрузки.
- R-14. Поля с `aiVisible` попадают в системный промпт агента отдельным блоком «Данные клиента с сайта» (`подпись: значение`, для enum — подпись значения, для boolean — да/нет). Блок помечен как сведения от сайта, а не инструкции. Остальные поля к модели не уходят.
- R-15. Данные с сайта не используются для авторизации, идентификации и объединения контактов.
- R-16. API диалога и контакта отдаёт `siteFields: [{ key, label, type, value, display, color?, updatedAt }]` в порядке схемы, без удалённых полей.
### Оператор
- R-17. Во вкладке «Контакт» контекст-панели чата — секция «Данные с сайта» между карточкой контакта и блоком «Диалог» (C1). В шапке — замок и «обновлено в HH:MM». Секции нет, если значений нет.
- R-18. Строка: подпись слева, значение справа. boolean — бейдж «Да» или «Нет», enum — бейдж цвета значения, ID и номера — моноширинно с «копировать», email, телефон и url — ссылка с «копировать», datetime — в формате организации.
- R-19. Значение, изменённое меньше 10 минут назад, подсвечено, справа — время изменения. Редактирования нет.
## Out of scope
- Подписанная передача данных (JWT от сервера сайта).
- Секция «Данные с сайта» в карточке раздела «Контакты» — второй этап, отдельным решением и макетом.
- Поиск и фильтрация контактов по своим полям, история значений вне ленты диалога.
- Своё поле в других подключениях (Telegram, MAX, VK, почта).
## Open questions
- Правило системного события (R-12): в описании макета оно помечено «обсудить». Предложено: только enum и boolean, независимо от «Видит AI». Альтернатива — ещё и любые поля с «Видит AI».
- Критерии приёмки: 4 поля (строка, да/нет, список, строка) создаются; `setFields` на сайте даёт значения в карточке оператора; смена `order_status` меняет бейдж без перезагрузки, пишет событие в ленту, и AI учитывает новый статус; неизвестный ключ и неверный тип не ломают виджет и не сохраняются; ручная правка имени оператором не затирается сайтом; значения одной организации недоступны другой.
@@ -0,0 +1,45 @@
---
id: "0020"
title: Форма перед чатом веб-виджета
status: accepted
date: 2026-09-29
---
## Problem
Некоторым организациям нужно знать, как зовут клиента и как с ним связаться, ещё до начала диалога. Сейчас перед чатом показывается только согласие. Спрашивать то, что сайт уже передал, нельзя — это раздражает клиента.
Утверждённый макет: `design/baseline/Веб-чат · поля и оформление/` (кадры W2 и M1). Зависит от спецификации «Данные с сайта в веб-чате»: свои поля и `setFields`. Меняет требование SPEC-0001 R-16 («обязательной анкеты нет»): анкета остаётся выключенной по умолчанию.
## Scenarios
1. Администратор в разделе «Форма перед чатом» (W2) включает форму, отмечает Имя, Email, Телефон и своё поле «Номер заказа», делает Имя и Email обязательными, задаёт заголовок и текст согласия.
2. Сайт передал имя, почту и номер заказа. Клиент открывает чат (M1) и видит форму: эти поля заполнены и помечены «с сайта», телефон пустой. Клиент при желании поправляет значения и нажимает «Начать чат».
3. Клиент стирает обязательное имя — «Начать чат» становится недоступной.
4. Клиент вернулся через день в той же сессии — формы нет, открывается переписка. Администратор поменял текст согласия — форма показывается снова.
5. Форма выключена — виджет ведёт себя как сейчас: согласие и «Начать чат».
## Requirements
- R-1. Настройка хранится в `integration.config.preChat`: `enabled`, `title`, `fields: [{ key, required }]`, где `key` — `name`, `email`, `phone` или ключ своего поля. По умолчанию форма выключена.
- R-2. Раздел «Форма перед чатом» (W2): включатель, список полей с чекбоксами (Имя, Email, Телефон и «Своё поле из „Данных с сайта“»), тумблер «Обязательное», заголовок формы, текст согласия. Ровно как в макете.
- R-3. Текст согласия — существующие `consentText` и `consentVersion`. При изменении текста сервер сам повышает версию.
- R-4. Сервер отклоняет `preChat` со ссылкой на несуществующее своё поле. Удаление своего поля из схемы убирает его из формы.
- R-5. Публичная конфигурация виджета отдаёт `preChat` и схемы полей, нужные форме.
- R-6. В виджете при включённой форме экран согласия и нижняя кнопка «Начать чат» заменяются формой (M1): аватар агента, заголовок, карточка с полями, текст согласия, кнопка «Начать чат».
- R-7. «Начать чат» недоступна, пока не заполнены обязательные поля или есть невалидные значения.
- R-8. Значение, пришедшее через `setFields`, показывается заполненным с пометкой «с сайта», клиент может его изменить.
- R-9. Контролы по типу поля: email и телефон — с проверкой формата (телефон — существующий `formatPhone`), boolean — переключатель, enum — выпадающий список, datetime — нативное поле даты и времени, остальное — текстовое поле.
- R-10. Значения формы уходят в старт сессии вместе с принятием согласия и имеют приоритет над `setFields`. На сервере они проходят ту же валидацию и запись, что и данные с сайта: встроенные поля — в контакт, свои — в значения полей.
- R-11. Если сессия есть и согласие этой версии уже дано, форма не показывается повторно.
- R-12. Все тексты виджета — через словари `web-chat/src/i18n/ru.ts` и `en.ts`; подписи своих полей берутся из схемы.
- R-13. До нажатия «Начать чат» модель не вызывается, диалог не начинается (SPEC-0001 R-5).
## Out of scope
- Условная логика полей, многошаговые анкеты, проверка почты кодом.
- Форма в других подключениях.
## Open questions
- Критерии приёмки: форма выключена — виджет работает как раньше; имя и почта пришли с сайта — предзаполнены с пометкой «с сайта»; без обязательных полей «Начать чат» недоступна; неверная почта не проходит; значения формы оказываются в контакте и в «Данных с сайта»; смена текста согласия показывает форму повторно.
@@ -0,0 +1,44 @@
---
id: "0021"
title: Оформление веб-виджета
status: accepted
date: 2026-09-29
---
## Problem
Виджет выглядит одинаково на всех сайтах: синяя круглая кнопка справа с нашим знаком. Цвет `accent` в конфигурации есть, но лоадер его не применяет: кнопка всегда `#1677ff`, а менять цвет в интерфейсе негде. Организациям нужно вписать виджет в свой сайт, не трогая вставленный код.
Утверждённый макет: `design/baseline/Веб-чат · поля и оформление/` (кадр W3 интерактивный; M1–M2 показывают результат).
## Scenarios
1. Администратор в разделе «Оформление» (W3) выбирает «Бирюзу». Предпросмотр справа и кнопка на сайте перекрашиваются без перевставки кода.
2. Администратор вводит свой HEX `#ffd400` и видит предупреждение «Белый текст плохо читается · контраст …». Сохранить всё равно можно.
3. Администратор загружает SVG-логотип для кнопки, переносит её влево, выбирает размер 64 и скруглённую форму. Панель открывается над кнопкой слева.
4. Администратор вставляет свой CSS с классом `.cb-header`: меняется шапка внутри окна чата, страница сайта не меняется. «Сбросить» очищает CSS.
5. Злоумышленник с правами администратора загружает SVG со `<script>`: сохранённый файл скрипта не содержит.
## Requirements
- R-1. Настройки — в `integration.config.appearance`: `accent` (`#RRGGBB`, существующий `config.accent`), `launcherIcon`, `headerIcon` (URL; `null` у шапки — без иконки), `launcherPosition` (`left | right`, по умолчанию `right`), `launcherSize` (`48 | 56 | 64`, по умолчанию 56), `launcherShape` (`circle | rounded | square`: 50%, 30%, 10px), `customCss`.
- R-2. Цвет: 8 пресетов — Синий `#1677ff`, Индиго `#4f46e5`, Бирюза `#0d8a7e`, Зелёный `#15803d`, Терракота `#c2410c`, Малина `#be123c`, Слива `#7e22ce`, Графит `#262626` — и поле «Свой HEX». Для своего цвета показывается контраст с белым: при значении меньше 4.5:1 — предупреждение, при неверном HEX — подсказка о формате. Сохранение не запрещается.
- R-3. Иконки: загрузка SVG или PNG до 256 КБ, PNG не меньше 96×96 — `POST /api/v1/integrations/{id}/assets/` → `{ url }`. Файлы лежат в текущем хранилище организации (`organizations/{public_id}/...`, диск или S3) и отдаются публично по URL. По умолчанию — стандартный знак агента. Иконка шапки по умолчанию совпадает с иконкой кнопки.
- R-4. SVG санитизируется на сервере до сохранения: удаляются `script`, атрибуты `on*`, `foreignObject`, внешние ссылки (`href` и `xlink:href` не на `#`), `javascript:`-адреса.
- R-5. Лоадер `chat-widget.js` сам получает публичную конфигурацию и применяет цвет, иконку, положение, размер и форму кнопки. Панель открывается над кнопкой с той же стороны, анимация «джин» и мобильный полноэкранный режим (SPEC-0001 R-14) сохраняются. Цвет тени и фокуса — от выбранного цвета.
- R-6. Публичная конфигурация отдаёт `appearance` целиком. Виджет применяет цвет и иконку шапки.
- R-7. Свой CSS — до 10 КБ. Сервер вырезает `@import`, `url(...)` с внешними адресами и `expression`. Виджет вставляет его `<style>` внутри iframe чата, на страницу сайта он не попадает. Кнопка «Сбросить» очищает поле.
- R-8. Ключевые элементы виджета получают стабильные классы: `cb-header`, `cb-body`, `cb-bubble`, `cb-bubble--client`, `cb-bubble--agent`, `cb-composer`, `cb-start-button`, `cb-form-field` и другие, перечисленные в справке. Базовые стили этих элементов не должны перебиваться только через `!important`: они выносятся в `<style>` с низкой специфичностью.
- R-9. Список классов опубликован в справке, раздел «Оформление» ссылается на неё («Классы виджета»).
- R-10. Раздел «Оформление» (W3) — ровно как в макете, с живым предпросмотром справа: цвет, кнопка, положение, размер, форма и иконки меняют предпросмотр сразу. Предпросмотр — упрощённая копия виджета, как в макете.
- R-11. Изменения применяются на сайте без перевставки кода; кэш лоадера и конфигурации не задерживает их дольше 5 минут.
## Out of scope
- Тёмная тема виджета, свои шрифты через загрузку файлов, несколько кнопок на странице.
- CSS, влияющий на страницу сайта (кнопку и тень на сайте рисует лоадер).
- Предпросмотр живым компонентом виджета внутри админки.
## Open questions
- Критерии приёмки: смена цвета, позиции и формы кнопки применяется на сайте без перевставки кода; SVG со `<script>` после загрузки не содержит скрипта; свой CSS меняет вид внутри чата и не влияет на страницу сайта; `@import` и внешние `url()` вырезаются; предупреждение о контрасте появляется для светлого цвета.
@@ -0,0 +1,26 @@
---
id: T-001
title: Команды CLI backup и restore
status: todo
depends_on: []
spec: "0005"
created: 2026-09-28
archived: true
---
## Цель
Реализовать в `./chatballs` резервное копирование и восстановление экземпляра вместо ручных `pg_dump` и архива медиа.
## Критерии приёмки
- [ ] `backup` создаёт копию: дамп PostgreSQL, архив медиа, три тома секретов и метаданные выпуска; копирование каталога работающего PostgreSQL не используется
- [ ] Redis в копию не входит
- [ ] `restore` проверяет совместимость выпуска и схемы, требует явного подтверждения (кроме авторизованного `--non-interactive`) и останавливает пишущие сервисы
- [ ] Пароли не принимаются аргументами командной строки и не попадают в вывод
- [ ] Команды не выполняются параллельно с другими операциями CLI; ошибка даёт ненулевой код возврата
- [ ] После восстановления реестр и состояние демо-набора согласованы
## Заметки
Сейчас не реализовано (известное расхождение в спецификации установки). Порядок операций не должен дублироваться в CI и ручных инструкциях — CI вызывает тот же entrypoint.
@@ -0,0 +1,26 @@
---
id: T-002
title: Команды CLI install, update и rollback
status: todo
depends_on:
- T-001
spec: "0005"
created: 2026-09-28
archived: true
---
## Цель
Реализовать в `./chatballs` первичную установку, обновление и откат по каноническому порядку развёртывания.
## Критерии приёмки
- [ ] `install` поднимает экземпляр на чистой машине без `.env` и заданных переменных
- [ ] `update` делает резервную копию, получает образы целевого выпуска по digest, выполняет `deploy` с миграциями и проверяет здоровье; секреты и демо-данные не меняются
- [ ] `rollback` восстанавливает предыдущий выпуск через восстановление из резервной копии и явно предупреждает, что откат необратимых миграций иначе невозможен
- [ ] Все команды поддерживают `--non-interactive`, fail-fast и блокировку параллельных операций
- [ ] Путь обновления совместим с обновлением из интерфейса (сервис `updater`) и не создаёт второй источник истины
## Заметки
Сейчас реализованы только `doctor`, `deploy`, `status`, `logs`. `rollback` зависит от `backup`/`restore`.
@@ -0,0 +1,23 @@
---
id: T-003
title: Разобраться с признаком режима поставки CLOUD / SELF_HOSTED
status: todo
depends_on: []
spec: "0005"
created: 2026-09-28
archived: true
---
## Цель
Устранить расхождение: в `README.md`, `scripts/start.sh`, `scripts/start.ps1` и бэкенде встречается признак режима поставки `CLOUD` / `SELF_HOSTED`, который не описан ни одним решением, а managed-облако в продукт не входит.
## Критерии приёмки
- [ ] Владелец решил, нужен ли признак после отказа от managed-облака
- [ ] Признак либо описан в спецификации установки с назначением и влиянием на поведение, либо удалён из README, скриптов и кода
- [ ] Установка по-прежнему не требует ни одной переменной окружения
## Заметки
Упоминания найдены в `apps/backend/chatballs_backend/settings_base.py`, `chatballs/platform/provisioning_models.py`, `chatballs/api/permissions.py`, `scripts/start.sh`, `scripts/start.ps1`. Источник провижининга `SELF_HOSTED_SETUP` используется при создании организаций. Решение за владельцем.
@@ -0,0 +1,23 @@
---
id: T-004
title: Публичная документация установки и конфигурации
status: todo
depends_on: []
created: 2026-09-28
archived: true
---
## Цель
Проверить и довести публичную документацию open-source репозитория (README, установка, конфигурация) до соответствия текущей поставке.
## Критерии приёмки
- [ ] README описывает установку одной командой, требования к хосту и открываемые порты, включая 3478 TCP/UDP и 49160–49999/udp для звонков
- [ ] Описаны сервисы стека, включая `worker-events`, `updater` и `coturn`, и три тома секретов в резервной копии
- [ ] Описаны обновление из интерфейса и вручную, а также ручной откат
- [ ] В публичной документации нет ссылок на приватные проектные документы и служебных кодов решений
## Заметки
Проектная документация приватна, публичная пишется отдельно. Релизы уже публикуются на GitHub, поэтому часть работы, вероятно, сделана — задача в первую очередь на сверку. Отдельный вопрос подготовки к публикации — чистый публичный репозиторий или чистка истории от ранее закоммиченных документов; текущий статус этого шага в источниках не указан.
@@ -0,0 +1,23 @@
---
id: T-005
title: Экран управления организациями для администратора установки
status: todo
depends_on: []
created: 2026-09-28
archived: true
---
## Цель
Дать администратору установки экран со списком организаций установки и действиями над ними.
## Критерии приёмки
- [ ] Есть утверждённый макет экрана в `design/baseline/`; реализация повторяет его без отступлений
- [ ] Экран виден только администратору установки; права проверяет backend
- [ ] Администратор установки не получает доступа к данным организации, в которой не состоит; доступ к данным — только через аудируемую support-сессию
- [ ] Все тексты — через словарь i18n, без служебной лексики
## Заметки
Создание организации из интерфейса уже есть («Добавить организацию» в переключателе, релиз 1.6.0). Экрана управления организациями нет; макета для него тоже нет — без макета не начинать. Экран управления самими администраторами установки в baseline тоже отсутствует и этой задачей не проектируется.
@@ -0,0 +1,24 @@
---
id: T-006
title: Клиентская часть интеграции voice-to-voice звонков
status: todo
depends_on: []
created: 2026-09-28
archived: true
---
## Цель
Реализовать в открытом репозитории интеграцию с закрытым облачным сервисом телефонных звонков с ИИ по API-ключу.
## Критерии приёмки
- [ ] Интеграция заводится и проверяется в «Настройки → Интеграции»; ключ шифруется как остальные секреты
- [ ] Номера телефонов привязываются к агентам
- [ ] Приём событий звонка собирает контекст агента (инструкции, знания), обслуживает инструменты, сохраняет транскрипт в диалог контакта и умеет передать разговор оператору
- [ ] Без оплаченного ключа интеграция ничего не делает и не ломает остальные функции
- [ ] В открытом репозитории нет телефонии, медиа, голосовых моделей, тарифов и учёта минут
## Заметки
В коде интеграции пока нет: среди провайдеров интеграций такого типа нет. Документы противоречат друг другу: по концепции продукта и решению об open source клиентская часть живёт в открытом репозитории, а по архитектуре «в открытой сборке кода, экранов и API нет». Принято: клиентская часть — в открытом коде, сам сервис — нет. Перед стартом нужна спецификация API сервиса и макет экранов.
@@ -0,0 +1,30 @@
---
id: T-007
title: Вынести меню разделов настроек в общий компонент
milestone: M01
status: done
depends_on: []
order: 1
spec: "0002"
created: 2026-09-29
branch: skaro/T-007-vynesti-menyu-razdelov-nastroe
---
## Цель
Страница подключения и настройки портала должны пользоваться одним меню разделов, а не двумя копиями. Меню портала выносится в общий компонент без изменения вида.
## Критерии приёмки
- [x] Меню разделов (пункты с иконкой, подсказкой, разделителем, опасный пункт) — общий компонент в shared/, настройки портала используют его
- [x] Вид настроек портала (кадры PT4–PT6 из «Порталы Baseline») не изменился
- [x] Классы portal-settings-nav* в новой странице не дублируются
- [x] Тесты и tsc по затронутым файлам проходят
## Заметки
Сейчас меню собрано в features/support-portals/PortalSettings.tsx и sections.ts. Правило «один стандарт на элемент» из AGENTS.md.
## Итог
Меню настроек портала вынесено в общий SectionMenu и CSS в shared, сохранены пункты с иконкой, подсказкой, разделителем и опасным пунктом. На штатном Docker dev-контуре установлен демо-набор, портал проверен в Chromium на кадрах PT4–PT6; целевой тест, tsc и сборка прошли.
@@ -0,0 +1,37 @@
---
id: T-008
title: Страница веб-подключения с разделами «Основные» и «Удалить подключение»
milestone: M01
status: done
depends_on:
- T-007
order: 2
spec: "0002"
created: 2026-09-29
branch: skaro/T-008-stranitsa-veb-podklyucheniya-s
---
## Цель
Редактирование WEB-подключения переезжает из модалки на страницу /settings/integrations/{id} с субменю: Основные · Данные с сайта · Форма перед чатом · Оформление · Удалить подключение (SPEC-0002 R-11).
## Критерии приёмки
- [x] Клик по веб-подключению в настройках открывает страницу /settings/integrations/{id}, прямая ссылка на неё работает после перезагрузки
- [x] «Основные» содержит название, разрешённые домены с прежней валидацией и код вставки; в шапке страницы — код вставки с кнопкой «Копировать»
- [x] «Удалить подключение» делает то же, что прежнее удаление, с подтверждением
- [x] Пункты «Данные с сайта», «Форма перед чатом», «Оформление» есть в меню, но их содержимое не маскируется под готовое — до своих задач раздел не показывается или показывает только заголовок по согласованию с владельцем
- [x] Создание подключения и редактирование других провайдеров по-прежнему в модалке IntegrationForm
- [x] Страница — composition-only, файлы не больше ~200–300 строк, все тексты через t()
## Заметки
Макет: design/baseline/Веб-чат · поля и оформление (шапка «Сайт obed.ru», субменю кадров W1–W3); раздел «Основные» в макете не нарисован — по README это текущее содержимое модалки. Сейчас: features/integrations/IntegrationForm.tsx, features/settings/SettingsPage.tsx. PATCH должен сохранять прочие ключи config (title, accent, consent и т.д.), а не затирать их.
## Итог
Тестовые учётные данные нашёл в [манифесте демо-набора](C:/Users/drmar/AppData/Roaming/Skaro/worktrees/70a06986-4881-4d6d-865c-fa6636fc00b4/T-008/apps/backend/chatballs/identity/demo_seed/data/ru/organization.json): учётные записи указаны в `accounts`, общий пароль — в `demoPassword`. Ссылку на источник и указание не запускать seed ради проверки добавил в [AGENTS.md](C:/Users/drmar/AppData/Roaming/Skaro/worktrees/70a06986-4881-4d6d-865c-fa6636fc00b4/T-008/AGENTS.md).
Задача T-008 готова и зафиксирована двумя коммитами. На штатном frontend и реальном backend проверил прямую ссылку после перезагрузки, поля и валидацию, копирование кода, сохранность остальных настроек, диалог удаления, недоступные будущие разделы и прежние модалки для создания и других провайдеров. Само удаление демо-подключения не выполнял; проверил подтверждение и прежний DELETE-вызов в коде. Сборка и 30 связанных тестов прошли, структура файлов проверена.
Skaro отметил все 6 критериев выполненными и показал карточку для слияния задачи.
@@ -0,0 +1,30 @@
---
id: T-009
title: Схема своих полей в конфигурации веб-подключения
milestone: M02
status: done
depends_on: []
order: 1
spec: "0019"
created: 2026-09-29
branch: skaro/T-009-shema-svoih-poley-v-konfigurat
---
## Цель
Сервер принимает и валидирует config.fields WEB-подключения и отдаёт схему виджету без aiVisible (R-1, R-2, R-8).
## Критерии приёмки
- [x] PATCH подключения с fields валидирует ключ ^[a-z][a-z0-9_]{0,39}$, уникальность, неизменяемость ключа, label до 60 символов, тип из списка, options только у enum
- [x] Больше 30 полей и ключи name/email/phone отклоняются понятной ошибкой через t()
- [x] Публичная конфигурация /webchat/config отдаёт fields без aiVisible
- [x] Тесты на валидацию и публичную конфигурацию проходят (--reuse-db, только затронутые)
## Заметки
integrations: сериализатор config WEB (camelCase в API, snake_case в БД); webchat/widgets.py ensure_widget → presentation_config; webchat/services.py public_config.
## Итог
Схема своих полей хранится в config.fields WEB-подключения и проверяется при PATCH: формат и уникальность ключа, запрет смены ключа по служебному id, который выдаёт сервер (так решил владелец), название до 60 символов, тип из списка, значения только у списка, не больше 30 полей, ключи name/email/phone заняты. Все ошибки идут через t(), в ru и en. Настройки подключения отдают схему в camelCase, а /webchat/config — без aiVisible и id. Если форма присылает config без fields, схема не затирается.
@@ -0,0 +1,40 @@
---
id: T-010
title: Хранение значений полей и приём данных с сайта на сервере
milestone: M02
status: done
depends_on:
- T-009
order: 2
spec: "0019"
created: 2026-09-29
branch: skaro/T-010-hranenie-znacheniy-poley-i-pri
---
## Цель
Появляется таблица значений полей и сервис, который валидирует данные с сайта и пишет их в контакт и в значения; данные принимают старт сессии и новый endpoint (R-9, R-10, R-11, R-15, ADR-0030).
## Критерии приёмки
- [x] Модель contact_field_values (организация, контакт, подключение, ключ, значение jsonb, updated_at) с уникальностью по контакт+подключение+ключ, под RLS; тест изоляции под runtime-ролью проходит
- [x] POST /api/v1/webchat/fields/ с токеном сессии и POST /webchat/session с fields сохраняют валидные значения; null очищает значение
- [x] Неизвестные ключи и неверные типы отбрасываются без ошибки для сайта, в журнал пишется предупреждение без значения; enum принимает только value из options, строки обрезаются/отклоняются свыше 500 символов
- [x] name/email/phone пишутся в контакт только если там пусто или прошлое значение пришло с этого же подключения — ручная правка оператора не затирается (тест)
- [x] Endpoint под теми же throttle, что остальные публичные по сессии
## Заметки
webchat/services.py issue_session, webchat/views.py, urls.py. Источник встроенного поля нужно где-то помнить (например, отметка в значениях с тем же ключом) — решить при реализации, не ломая модель Contact без нужды.
## Итог
Задача T-010 выполнена в ветке, коммит `2df2c17c`. Skaro отметил все пять критериев приёмки и показал карточку слияния.
1. Таблица значений и RLS: миграции применились; тест под runtime-ролью подтвердил изоляцию организаций.
2. Старт сессии и `POST /api/v1/webchat/fields/`: тесты подтвердили сохранение значений и очистку через `null`.
3. Валидация: неизвестные ключи, неверные типы, значения enum вне списка и слишком длинные строки отбрасываются; предупреждения не содержат значений.
4. Ручные правки: тесты подтвердили, что сайт не затирает изменённые оператором имя, email и телефон.
5. Ограничения запросов: тест подтвердил требование токена и ответ `429` при исчерпании лимита записи.
Адресные тесты, проверка миграций, Django check и Ruff прошли.
@@ -0,0 +1,32 @@
---
id: T-011
title: Событие в ленте, обновление по WebSocket и siteFields в API
milestone: M02
status: done
depends_on:
- T-010
order: 3
spec: "0019"
created: 2026-09-29
branch: skaro/T-011-sobytie-v-lente-obnovlenie-po
---
## Цель
Изменение значения видно оператору сразу: системное событие в ленте открытого диалога, событие по WebSocket и поле siteFields в API диалога и контакта (R-12, R-13, R-16).
## Критерии приёмки
- [x] Новый код SystemEvent для обновления данных сайта; параметры — подпись, старое и новое отображаемое значение; фразу собирает бэкенд через t() на языке читателя, ключи есть в ru и en
- [x] Событие пишется только для полей enum и boolean и только при фактическом изменении значения при открытом диалоге
- [x] Оператор получает событие по WebSocket, useConversationEvents обновляет карточку и ленту без перезагрузки
- [x] API диалога и контакта отдаёт siteFields [{key,label,type,value,display,color?,updatedAt}] в порядке схемы, без удалённых полей
- [x] Тесты событий, realtime и каталога i18n проходят (только затронутые)
## Заметки
conversations/models.py SystemEvent (+миграция choices), serializers.py _system_text, features/conversations/useConversationEvents.ts.
## Итог
Ветка T-011 перенесена через git rebase main на 416e37e4; конфликт webchat/services.py разрешён с сохранением sessions/configuration и preChatFields из main, а история сообщений подключена из message_history. Переписанные коммиты 3ba1b606 и d7ea4a97 сохранены, рабочее дерево чистое. Повторные адресные проверки после разрешения конфликта прошли.
@@ -0,0 +1,31 @@
---
id: T-012
title: Блок «Данные клиента с сайта» в промпте агента
milestone: M02
status: done
depends_on:
- T-010
order: 4
spec: "0019"
created: 2026-09-29
branch: skaro/T-012-blok-dannye-klienta-s-sayta-v
---
## Цель
AI отвечает с учётом полей с включённым «Видит AI» (R-14).
## Критерии приёмки
- [x] Для диалога веб-чата в системный промпт добавляется блок с полями aiVisible: «подпись: значение», enum — подпись значения, boolean — да/нет
- [x] Блок явно помечен как сведения от сайта, не инструкции; поля без aiVisible и удалённые поля в промпт не попадают
- [x] Нет значений — нет блока
- [x] Тест сборки промпта проходит
## Заметки
ai/runtime.py — там собираются части промпта (agent_system_prompt, knowledge_catalog). Системный промпт не переводится (правило i18n). ADR-0004 — минимизация.
## Итог
В системный промпт веб-диалога добавлен отдельный блок «Данные клиента с сайта» только для разрешённых актуальной схемой полей. Enum выводятся подписями, boolean — да/нет; блок обозначен как недоверенные сведения, пустой контекст пропускается, обновления читаются при каждом ходе. Изменения проверены 22 адресными тестами и закоммичены в 5507ae9d.
@@ -0,0 +1,32 @@
---
id: T-013
title: Chatballs.setFields в лоадере и передача полей из виджета
milestone: M02
status: done
depends_on:
- T-010
order: 5
spec: "0019"
created: 2026-09-29
branch: skaro/T-013-chatballs-setfields-v-loadere
---
## Цель
Сайт может вызывать Chatballs.setFields() в любой момент, даже до загрузки скрипта, и значения доходят до сервера (R-5, R-6, R-7).
## Критерии приёмки
- [x] window.Chatballs с очередью q и методом setFields; вызовы до загрузки лоадера обрабатываются; window.ChatballsChat продолжает работать
- [x] Лоадер передаёт поля в свой iframe через postMessage({type:"chatballs-set-fields", fields}) с проверкой origin
- [x] Виджет сливает вызовы по ключам, null очищает; отправка не чаще раза в 500 мс
- [x] До старта сессии поля держатся в памяти и уходят вместе с startSession, после — POST /webchat/fields/
- [x] Сбой отправки не ломает виджет и страницу сайта
## Заметки
webchat/loader.py (LOADER_JS), web-chat/src/api.ts, App.tsx. Не раздувать App.tsx — вынести в отдельный модуль/хук.
## Итог
Ветка перенесена через git rebase main на b160335b; конфликт loader.py разрешён с сохранением оформления кнопки и модульной структуры из main. Передача полей встроена в loader_assets, дублирующие loader_scripts удалены; итоговый коммит e4f25642, рабочее дерево чистое. После разрешения конфликта прошли сборка, 6 Vitest-тестов передачи полей, 5 Node-тестов лоадера, Ruff, Django check и браузерная проверка реального dev-контура задачи.
@@ -0,0 +1,32 @@
---
id: T-014
title: Секция «Данные с сайта» в карточке контакта чата
milestone: M02
status: done
depends_on:
- T-011
order: 6
spec: "0019"
created: 2026-09-29
branch: skaro/T-014-sektsiya-dannye-s-sayta-v-kart
---
## Цель
Оператор видит данные сайта во вкладке «Контакт» между карточкой и блоком «Диалог», ровно как в кадре C1 (R-17, R-18, R-19).
## Критерии приёмки
- [x] Секция через ContextSection, с замком и «обновлено в HH:MM» в шапке; нет значений — нет секции
- [x] boolean — бейдж «Да»/«Нет», enum — бейдж цвета значения, ID/номера — моноширинно с «копировать», email/phone/url — ссылка .link с «копировать», datetime — через fmt
- [x] Значение, изменённое меньше 10 минут назад, подсвечено и показывает время изменения; подсветка гаснет без перезагрузки
- [x] Редактирования нет; вид совпадает с кадром C1
- [x] Компонент отдельный, в ClientContext.tsx только подключение; цвета через токены (цвет enum — из данных)
## Заметки
features/sales/dialogs/context/ClientContext.tsx, features/conversations/ContextSection.tsx, shared CopyButton. Макет C1: design/baseline/Веб-чат · поля и оформление.
## Итог
Реализована секция данных сайта между карточкой контакта и блоком «Диалог»: типизированные значения, копирование, замок, время обновления и автоматическое погасание подсветки. После явного разрешения владельца создано отдельное подключение 22 и реальный диалог 31; браузерная проверка по C1 выполнена на штатном backend, в светлой и тёмной темах, без подмены API. Исправлен обнаруженный приоритет CSS заголовка; коммиты 15b24fe9 и a0c01c95, сборка и адресные тесты прошли.
@@ -0,0 +1,33 @@
---
id: T-015
title: Раздел «Данные с сайта» в настройках подключения
milestone: M02
status: done
depends_on:
- T-008
- T-009
order: 7
spec: "0019"
created: 2026-09-29
branch: skaro/T-015-razdel-dannye-s-sayta-v-nastro
---
## Цель
Администратор управляет своими полями в разделе W1 ровно как в макете (R-3, R-4).
## Критерии приёмки
- [x] Таблица «Поля контакта» (Имя, Email, Телефон) только для чтения
- [x] Таблица «Свои поля»: добавление, перетаскивание порядка, тип через select, чипы значений списка «label · value» и «+ значение» с цветом, тумблер «Видит AI», меню ⋯ (antd Dropdown, app-dropdown) — переименовать, удалить
- [x] Блок с примером кода Chatballs.setFields и копированием
- [x] Ошибки сервера (лимит 30, резерв ключа, неверный ключ) показываются по-человечески, без служебных слов
- [x] Счётчик полей в пункте меню, все тексты через t() в ru и en, файлы в пределах NO GOD FILES
## Заметки
Макет W1. Удаление поля не удаляет значения — это делает сервер (R-4), UI только сохраняет схему.
## Итог
Добавлен раздел «Данные с сайта» по W1: поля контакта только для чтения, редактор своих полей со стандартными диалогами, перетаскиванием, типами, цветными значениями списка, «Видит AI» и меню antd. Проверены сборка, 18 связанных тестов и браузерные сценарии на frontend T-015 (15175) с реальным backend основной копии (8010), без заглушек и seed; исходная схема демо-подключения восстановлена. Изменения зафиксированы коммитом 0f2c6f67.
@@ -0,0 +1,28 @@
---
id: T-016
title: Настройка формы перед чатом на сервере
milestone: M03
status: done
depends_on:
- T-010
order: 1
spec: "0020"
created: 2026-09-29
branch: skaro/T-016-nastroyka-formy-pered-chatom-n
---
## Цель
Сервер хранит и валидирует config.preChat, сам повышает версию согласия и принимает значения формы при старте сессии (R-1, R-3, R-4, R-5, R-10).
## Критерии приёмки
- [x] PATCH подключения валидирует preChat: enabled, title, fields [{key, required}], key — name/email/phone или существующее своё поле; по умолчанию выключено
- [x] Изменение consentText повышает consentVersion на сервере
- [x] Удаление своего поля из схемы убирает его из preChat
- [x] Публичная конфигурация отдаёт preChat; старт сессии принимает значения формы, они проходят ту же валидацию и запись, что setFields, и имеют приоритет над ними
- [x] Тесты проходят (только затронутые)
## Итог
Реализованы хранение и валидация config.preChat, серверное повышение consentVersion при изменении текста и удаление ссылок на удалённые свои поля. Публичный API отдаёт форму и схему; старт сессии принимает preChatFields с приоритетом над fields до общего валидатора и записи в контакт/значения полей. Коммит 7e84a254; адресные проверки прошли на исходниках T-016 в штатном backend-admin с отдельной тестовой БД test_chatballs_t016.
@@ -0,0 +1,29 @@
---
id: T-017
title: Раздел «Форма перед чатом» в настройках подключения
milestone: M03
status: done
depends_on:
- T-008
- T-016
- T-015
order: 2
spec: "0020"
created: 2026-09-29
branch: skaro/T-017-razdel-forma-pered-chatom-v-na
---
## Цель
Администратор настраивает форму в разделе W2 ровно как в макете (R-2).
## Критерии приёмки
- [x] Включатель формы, список полей с чекбоксами Имя/Email/Телефон и «Своё поле из „Данных с сайта“», тумблер «Обязательное», заголовок формы, текст согласия
- [x] Источник поля подписан как в макете (например «name · с сайта»)
- [x] В пункте меню — подсказка «вкл», когда форма включена
- [x] Все тексты через t() в ru и en, вид совпадает с кадром W2
## Итог
Реализован раздел «Форма перед чатом» по W2: включатель, встроенные и свои поля, обязательность, заголовок, согласие, серверная редакция и подсказка «вкл». Проверены сборка, адресные тесты, браузерные сценарии и размеры макета на frontend T-017:15177 с реальным backend основной копии:8010; изменения зафиксированы коммитом 634563c9. Демо-конфигурация восстановлена, серверная редакция согласия стала v3 после изменения текста и его возврата.
@@ -0,0 +1,33 @@
---
id: T-018
title: Форма перед чатом в виджете
milestone: M03
status: done
depends_on:
- T-016
- T-013
order: 3
spec: "0020"
created: 2026-09-29
branch: skaro/T-018-forma-pered-chatom-v-vidzhete
---
## Цель
Клиент видит форму вместо согласия, как в кадре M1, и начинает чат только с заполненными обязательными полями (R-6 – R-9, R-11 – R-13).
## Критерии приёмки
- [x] При включённой форме Consent и StartChatFooter заменяются формой: аватар агента, заголовок, карточка полей, текст согласия, «Начать чат»
- [x] «Начать чат» недоступна без обязательных полей и при невалидных email/телефоне (formatPhone); контролы по типу: переключатель, select, нативный datetime
- [x] Значения из setFields показаны заполненными с пометкой «с сайта» и редактируемы
- [x] Форма не показывается повторно при действующей сессии и той же версии согласия; модель не вызывается до «Начать чат»
- [x] ChatView.tsx разделён: форма — отдельные компоненты, ни один файл не превышает ~300 строк; ключи в web-chat/src/i18n/ru.ts и en.ts
## Заметки
Макет M1. web-chat/src/ChatView.tsx (уже 321 строка), App.tsx.
## Итог
Реализована форма перед чатом по M1: типовые поля, проверка обязательности и формата, предзаполнение из setFields и приоритет правок клиента. Согласие сохраняется вместе с его версией, повторное принятие сохраняет действующую сессию. Коммит 7dc5cf88; сборка и адресные тесты прошли, браузерная проверка выполнена через task-local Playwright на реальном backend, временные настройки подключения полностью восстановлены.
@@ -0,0 +1,26 @@
---
id: T-019
title: Загрузка иконок виджета с очисткой SVG
milestone: M04
status: done
depends_on: []
order: 1
spec: "0021"
created: 2026-09-29
branch: skaro/T-019-zagruzka-ikonok-vidzheta-s-och
---
## Цель
Администратор загружает SVG или PNG для кнопки и шапки; файл безопасен и отдаётся публично (R-3, R-4).
## Критерии приёмки
- [x] POST /api/v1/integrations/{id}/assets/ принимает SVG/PNG до 256 КБ, PNG от 96×96, возвращает {url}; остальное отклоняется понятной ошибкой
- [x] SVG очищается на сервере: script, on*, foreignObject, внешние href/xlink:href, javascript: удалены (тест с вредным SVG)
- [x] Файл лежит в хранилище организации organizations/{public_id}/…, работает и на диске, и на S3
- [x] Файл отдаётся виджету на чужом сайте без авторизации, чужая организация не может перезаписать
## Итог
Добавлен POST /api/v1/organizations/{org}/integrations/{id}/assets/: он принимает SVG или PNG до 256 КБ (PNG от 96×96) и возвращает { url }. SVG очищается на сервере до сохранения. Файл хранится в папке организации на диске или в S3 и отдаётся без авторизации по непредсказуемому UUID (GET /api/v1/webchat/assets/<uuid>/). Модель WidgetAsset защищена RLS, триггерами связей и каталогом входа. При удалении интеграции её иконки стираются.
@@ -0,0 +1,26 @@
---
id: T-020
title: Настройки оформления на сервере и очистка своего CSS
milestone: M04
status: done
depends_on: []
order: 2
spec: "0021"
created: 2026-09-29
branch: skaro/T-020-nastroyki-oformleniya-na-serve
---
## Цель
Сервер хранит и валидирует config.appearance и отдаёт его виджету (R-1, R-6, R-7 серверная часть, R-11).
## Критерии приёмки
- [x] PATCH подключения валидирует appearance: accent #RRGGBB, позиция, размер 48/56/64, форма, URL иконок, customCss до 10 КБ
- [x] Из customCss вырезаются @import, url() с внешними адресами и expression (тест)
- [x] Публичная конфигурация отдаёт appearance; существующий config.accent продолжает работать
- [x] Кэш конфигурации и лоадера не задерживает изменения дольше 5 минут
## Итог
Сервер хранит и проверяет config.appearance веб-подключения: цвет, положение, размер, форму, иконки и свой CSS до 10 КБ. Из CSS вырезаются @import, внешние url() и expression. Публичная конфигурация отдаёт appearance целиком, config.accent продолжает работать, кэш не задерживает изменения дольше 5 минут. Ветка перенесена на свежую main, конфликты со схемой своих полей разрешены, обе части сохранены.
@@ -0,0 +1,31 @@
---
id: T-021
title: Лоадер применяет оформление кнопки
milestone: M04
status: done
depends_on:
- T-020
order: 3
spec: "0021"
created: 2026-09-29
branch: skaro/T-021-loader-primenyaet-oformlenie-k
---
## Цель
Кнопка на сайте получает цвет, иконку, положение, размер и форму из конфигурации (R-5).
## Критерии приёмки
- [x] Лоадер запрашивает публичную конфигурацию и применяет accent (включая тень и фокус), иконку, left/right, 48/56/64, circle/rounded/square
- [x] Панель открывается над кнопкой с той же стороны; анимация «джин» и полноэкранный режим на телефоне работают с обеих сторон
- [x] Ошибка конфигурации не ломает страницу: кнопка остаётся в стандартном виде
- [x] Проверено на тестовой странице для обеих сторон и трёх размеров
## Заметки
webchat/loader.py — цвет сейчас зашит #1677ff, в genie-анимации тоже (paintGenie).
## Итог
Ветка T-021 перенесена через git rebase main на 223a962c, конфликт views.py разрешён. Общие публичные обработчики используются из access.py свежей main, дубликат public_views.py удалён; передача preChatFields сохранена, обработчики конфигурации с CORS и лоадера сохранены. Новый коммит a0eb9f31: связанные проверки прошли, исходные настройки dev-подключения восстановлены.
@@ -0,0 +1,31 @@
---
id: T-022
title: Стабильные классы, иконка шапки и свой CSS в виджете
milestone: M04
status: done
depends_on:
- T-020
order: 4
spec: "0021"
created: 2026-09-29
branch: skaro/T-022-stabilnye-klassy-ikonka-shapki
---
## Цель
Внутри окна чата можно менять вид своим CSS; виджет применяет цвет и иконку шапки (R-6, R-7, R-8).
## Критерии приёмки
- [x] Ключевые элементы получают классы cb-header, cb-body, cb-bubble, cb-bubble--client, cb-bubble--agent, cb-composer, cb-start-button, cb-form-field и др.; список зафиксирован в коде одним местом
- [x] Базовые стили этих элементов вынесены в <style> с низкой специфичностью, правило пользователя без !important их перебивает (проверено на примере из макета)
- [x] customCss вставляется <style> внутри iframe и не влияет на страницу сайта
- [x] Шапка показывает загруженную иконку, по умолчанию — иконку кнопки, null — без иконки
## Заметки
Виджет свёрстан inline-стилями (web-chat/src/ChatView.tsx и др.). Не менять внешний вид по умолчанию.
## Итог
Виджет получает стабильные CSS-классы, базовые стили с нулевой специфичностью, пользовательский CSS внутри iframe и цвет/иконку шапки из appearance. Большие компоненты разделены без изменения базового оформления; сборка, 5 целевых тестов и браузерная проверка на реальном backend основной копии прошли. Изменения закоммичены в ac5f328f; настройки проверенного подключения и его прежний статус восстановлены.
@@ -0,0 +1,30 @@
---
id: T-023
title: Раздел «Оформление» в настройках подключения
milestone: M04
status: done
depends_on:
- T-008
- T-019
- T-020
order: 5
spec: "0021"
created: 2026-09-29
branch: skaro/T-023-razdel-oformlenie-v-nastroykah
---
## Цель
Администратор настраивает вид виджета в разделе W3 с живым предпросмотром, ровно как в макете (R-2, R-10).
## Критерии приёмки
- [x] 8 пресетов цвета и «Свой HEX» с подсказкой контраста: ≥4.5:1 — ок, меньше — предупреждение, неверный HEX — подсказка формата; сохранение не запрещено
- [x] Загрузка иконок кнопки и шапки, переключатели положения, размера и формы
- [x] Поле своего CSS со ссылкой «Классы виджета» и кнопкой «Сбросить»
- [x] Предпросмотр справа меняется сразу; цвета пресетов — данные, а не сырые hex в стилях компонентов
- [x] Все тексты через t() в ru и en, вид совпадает с кадром W3, файлы в пределах NO GOD FILES
## Итог
Ветка перенесена на main 52524287, четыре конфликта разрешены с сохранением раздела «Данные с сайта», счетчика полей, типов и обоих словарей. Результат зафиксирован переписанным коммитом 1184c6d7; рабочая папка чистая, main является предком HEAD. После разрешения конфликтов прошли сборка internal-ui, 20 связанных тестов и браузерная проверка всех трех разделов на реальном backend.
@@ -0,0 +1,29 @@
---
id: T-024
title: Статья справки «Классы виджета»
milestone: M04
status: done
depends_on:
- T-022
order: 6
spec: "0021"
created: 2026-09-29
branch: skaro/T-024-statya-spravki-klassy-vidzheta
---
## Цель
Администратор знает, какие классы можно стилизовать своим CSS (R-9).
## Критерии приёмки
- [x] В справке опубликована и доступна по адресу /articles/klassy-vidzheta статья со списком классов виджета, назначением и примером CSS; проверка ссылки из раздела «Оформление» относится к T-023 и не блокирует приёмку T-024.
- [x] Статья совпадает со списком классов в коде.
## Заметки
Статьи справки: docs/portal-curation/chatballs.json (там же статья veb-vidzhet); ссылки — shared/help helpArticleUrl.
## Итог
Статья «Классы виджета» опубликована и доступна в действующем центре помощи по /articles/klassy-vidzheta; применены подготовленные порядок и связи статей. Браузерная проверка и реальный API подтвердили таблицу, назначение и CSS-пример, а опубликованный текст и все 26 классов совпадают с исходниками. Оба актуальных критерия T-024 проверены и выполнены; изменения репозитория находятся в коммите b8654529.
+2 -1
View File
@@ -11,6 +11,7 @@
Используй glab, gh если доступны.
Учетные данные / доступы:
- Для штатной локальной браузерной проверки тестовые учётные записи демо-сотрудников перечислены в `apps/backend/chatballs/identity/demo_seed/data/ru/organization.json` (`accounts`, включая роль `ADMIN`); общий пароль — поле `demoPassword` того же файла. Это также описано в `.skaro/specs/0006-demonstratsionnye-dannye.md` основной копии проекта (R-5). Проверить, что демо-набор уже установлен в используемой базе; не запускать seed ради проверки.
- Без явного указания владельца не менять, не сбрасывать, не bootstrap-ить и не пересоздавать никакие учетные данные, пароли, TOTP, recovery-коды, сессии и права доступа.
- Если для проверки нужен вход в приложение, использовать только уже существующие учетные данные, предоставленные владельцем, и не выполнять команды, которые могут изменить пароль или состояние аккаунта.
- Любые команды управления аккаунтами, включая `bootstrap_owner`, password reset/change, seed пользователей и изменение ролей, требуют отдельного явного подтверждения владельца перед запуском.
@@ -33,7 +34,7 @@ UI / дизайн:
- Перед тем как написать свой элемент, искать существующий в `shared/` и в соседних фичах. Второй стандарт того же элемента — это дефект, а не свобода реализации.
Никаких служебных записок в продукте:
- В продуктовом UI не должно быть служебной лексики. Запрещены на экране: коды спек и решений (SPEC-HUB-*, ADR-HUB-*, DG-*), номера правил (P1-P5 и любые другие), слова «инвариант», «констрейнт», «миграция», «scope» в техническом смысле, имена таблиц, полей и enum-значений БД и API (`allowCheckoutActions`, `Conversation.channel`, `PROTECT`, `OK`/`ERROR` как есть), ссылки на внутренние документы и любые пометки для разработчика.
- В продуктовом UI не должно быть служебной лексики. Запрещены на экране: коды спек и решений (SPEC-*, ADR-*, DG-*), номера правил (P1-P5 и любые другие), слова «инвариант», «констрейнт», «миграция», «scope» в техническом смысле, имена таблиц, полей и enum-значений БД и API (`allowCheckoutActions`, `Conversation.channel`, `PROTECT`, `OK`/`ERROR` как есть), ссылки на внутренние документы и любые пометки для разработчика.
- Пользователю показывается следствие и способ исправить, а не внутреннее правило. «Коммерческие действия недоступны непродуктовому каналу. Назначьте продукт» — да. «Запрещено инвариантом P1» — нет.
- Технические коды остаются в коде, комментариях, логах и API-ответах, но не в текстах интерфейса.
- Это относится и к baseline-макетам: служебный текст, попавший в макет, не является основанием выводить его на экран. Макет реализуется, дополняется по указанию владельца, служебная лексика в реализацию не переносится.
+1 -1
View File
@@ -1,4 +1,4 @@
# Caddyfile — единый HTTP/HTTPS public boundary Chatballs (ADR-HUB-0028 §gateway).
# Caddyfile — единый HTTP/HTTPS public boundary Chatballs (ADR-CHATBALLS-0028 §gateway).
# Подставляется в release bundle и монтируется в контейнер gateway.
#
# Свежая установка не знает своего домена: человек поднимает докер на сервере и
+12 -1
View File
@@ -130,6 +130,17 @@ After creating a web widget, add one tag to your site:
The chat opens in an isolated window on top of the site.
Pass visitor data with `Chatballs.setFields()`. To call it before the asynchronous script loads, declare a queue before the widget script tag:
```html
<script>
window.Chatballs = window.Chatballs || { q: [], setFields: function (fields) { this.q.push(fields); } };
Chatballs.setFields({ name: "Ivan", email: "ivan@example.com" });
</script>
```
Each call updates only the supplied keys; `null` clears a value. Add custom fields in the web integration settings first. The widget combines updates and sends them at most once every 500 ms. Values are never used to authorize a customer.
### Calls
Calls work right after the installation. Between browsers the conversation goes directly; when one side sits behind strict NAT or on a VPN it goes through the relay, which starts together with the stack on the same address. Nothing to configure: the relay addresses appear in **Settings → TURN for calls** on their own, derived from the installation address, and are only changed if you run your own server.
@@ -220,7 +231,7 @@ Phone numbers, email addresses and long numeric identifiers are stripped from te
<details>
<summary><strong>The stack does not start: port 80 or 443 is busy</strong></summary>
The gateway publishes ports 80 and 443. Free them, or bind the gateway to a specific IP with the `CHATBALLS_WEB_LISTENING_IP` variable.
The gateway publishes ports 80 and 443. Free them, bind the gateway to a specific IP with the `CHATBALLS_WEB_LISTENING_IP` variable, or use a [Compose override behind existing nginx](docs/deployment-overrides.md).
</details>
<details>
+12 -1
View File
@@ -130,6 +130,17 @@ docker compose up -d --wait
Чат откроется в изолированном окне поверх сайта.
Данные посетителя передаются через `Chatballs.setFields()`. Чтобы вызвать его до загрузки асинхронного скрипта, объявите очередь перед тегом подключения:
```html
<script>
window.Chatballs = window.Chatballs || { q: [], setFields: function (fields) { this.q.push(fields); } };
Chatballs.setFields({ name: "Иван", email: "ivan@example.com" });
</script>
```
Каждый вызов обновляет только переданные ключи; `null` очищает значение. Свои поля сначала добавьте в настройках веб-подключения. Виджет объединяет обновления и отправляет их не чаще раза в 500 мс. Значения не используются для авторизации клиента.
### Звонки
Звонки работают сразу после установки. Между браузерами разговор идёт напрямую, а если одна из сторон за строгим NAT или в VPN — через relay, который поднимается вместе со стеком на том же адресе. Настраивать нечего: адреса relay появляются в **Настройки → TURN для звонков** сами, от адреса установки, и меняются только если вы ставите свой сервер.
@@ -220,7 +231,7 @@ Telegram, MAX, ВКонтакте, электронная почта и чат
<details>
<summary><strong>Стек не поднимается: порт 80 или 443 занят</strong></summary>
Шлюз публикует порты 80 и 443. Освободите их или привяжите шлюз к конкретному IP через переменную `CHATBALLS_WEB_LISTENING_IP`.
Шлюз публикует порты 80 и 443. Освободите их, привяжите шлюз к конкретному IP через переменную `CHATBALLS_WEB_LISTENING_IP` или используйте [Compose override за существующим nginx](docs/deployment-overrides.ru.md).
</details>
<details>
+1 -1
View File
@@ -1 +1 @@
1.14.1
1.16.0
+1 -1
View File
@@ -22,7 +22,7 @@ COPY apps/backend /app/apps/backend
COPY deploy/secrets/generate-instance-secrets.sh /usr/local/bin/chatballs-generate-secrets.sh
RUN chmod 0755 /usr/local/bin/chatballs-generate-secrets.sh
# collectstatic в образе (ADR-HUB-0028): STATIC_ROOT испечён, runtime-шаг не нужен.
# collectstatic в образе (ADR-CHATBALLS-0028): STATIC_ROOT испечён, runtime-шаг не нужен.
# Build-time secret нужен только чтобы settings загрузились в production-режиме;
# collectstatic не обращается к БД/Redis/S3. whitenoise раздаёт static в runtime.
# Build-time dummy values satisfy C04 runtime guards (distinct DB users in
@@ -228,7 +228,7 @@ class AgentCardDeactivateView(_AgentCardStatusView):
class AgentCardTestChatView(APIView):
permission_classes = [HasCapability]
# Исполняет агента, а не изменяет канал: остаётся на ai.manage (ADR-HUB-0037 §9).
# Исполняет агента, а не изменяет канал: остаётся на ai.manage.
required_capability = "ai.manage"
def post(self, request: Request, agent_id: int) -> Response:
+28 -14
View File
@@ -31,18 +31,25 @@ from chatballs.ai.provider.base import (
from chatballs.ai.provider.factory import get_provider
from chatballs.ai.provider.resilience import CircuitBreaker, call_with_resilience
# Предохранитель считает сбои по ключу «организация + интеграция»: провайдер у
# каждой организации свой, и отозванный ключ одной не имеет отношения к AI
# остальных. Общий на процесс предохранитель гасил AI у всех сразу.
_breakers: dict[tuple[int, int], CircuitBreaker] = {}
@dataclass(slots=True)
class _BreakerSlot:
revision: int
breaker: CircuitBreaker
def _breaker(key: tuple[int, int]) -> CircuitBreaker:
breaker = _breakers.get(key)
if breaker is None:
breaker = CircuitBreaker()
_breakers[key] = breaker
return breaker
_breakers: dict[tuple[int, int], _BreakerSlot] = {}
def _breaker(key: tuple[int, int], revision: int) -> CircuitBreaker:
slot = _breakers.get(key)
if slot is None or slot.revision != revision:
slot = _BreakerSlot(revision=revision, breaker=CircuitBreaker())
_breakers[key] = slot
return slot.breaker
def reset_breakers() -> None:
@@ -51,13 +58,14 @@ def reset_breakers() -> None:
_breakers.clear()
def _breaker_key(channel) -> tuple[int, int]:
def _breaker_identity(channel) -> tuple[tuple[int, int], int]:
"""Ключ предохранителя. Без канала провайдер может быть только тестовым —
считать сбои там не по чему, и общий ключ (0, 0) никому не мешает."""
if channel is None:
return (0, 0)
return (channel.organization_id, routing.integration_id(channel))
return (0, 0), 0
integration_id, revision = routing.integration_runtime_identity(channel)
return (channel.organization_id, integration_id), revision
def _elapsed_ms(started: float) -> int:
@@ -72,6 +80,7 @@ class ChatJob:
model: str
messages: list[ChatMessage]
breaker_key: tuple[int, int]
breaker_revision: int
params: dict | None = None
@@ -83,6 +92,7 @@ class EmbeddingJob:
model: str
texts: list[str]
breaker_key: tuple[int, int]
breaker_revision: int
def _effective_model(channel, requested_model: str | None) -> str:
@@ -109,11 +119,13 @@ def prepare_chat(
) -> ChatJob:
"""Шаг в транзакции: провайдер, модель и очищенный от ПДн текст запроса."""
breaker_key, breaker_revision = _breaker_identity(channel)
return ChatJob(
provider=get_provider(channel=channel, timeout=timeout),
model=_effective_model(channel, model),
messages=[ChatMessage(role=item.role, content=redact(item.content)) for item in messages],
breaker_key=_breaker_key(channel),
breaker_key=breaker_key,
breaker_revision=breaker_revision,
params=params,
)
@@ -124,7 +136,7 @@ def run_chat(job: ChatJob) -> ChatResult:
return call_with_resilience(
lambda: job.provider.chat(messages=job.messages, model=job.model, params=job.params),
retries=settings.CHATBALLS_AI_MAX_RETRIES,
breaker=_breaker(job.breaker_key),
breaker=_breaker(job.breaker_key, job.breaker_revision),
)
@@ -200,11 +212,13 @@ def prepare_embedding(
) -> EmbeddingJob:
"""Шаг в транзакции: провайдер эмбеддингов организации."""
breaker_key, breaker_revision = _breaker_identity(channel)
return EmbeddingJob(
provider=get_provider(channel=channel, timeout=timeout),
model=model,
texts=texts,
breaker_key=_breaker_key(channel),
breaker_key=breaker_key,
breaker_revision=breaker_revision,
)
@@ -214,7 +228,7 @@ def run_embedding(job: EmbeddingJob) -> list[EmbeddingResult]:
return call_with_resilience(
lambda: job.provider.embed(texts=job.texts, model=job.model),
retries=settings.CHATBALLS_AI_MAX_RETRIES,
breaker=_breaker(job.breaker_key),
breaker=_breaker(job.breaker_key, job.breaker_revision),
)
@@ -1,4 +1,4 @@
# Generated for CustoAI / BYOK credential mode (ADR-HUB-0033 §4, SPEC-CHATBALLS-0024 §2).
# Историческое поле режима доступа к AI; удалено в 0016.
from django.db import migrations, models
@@ -14,8 +14,8 @@ class Migration(migrations.Migration):
model_name='aiagent',
name='credential_mode',
field=models.CharField(
choices=[('CUSTOAI', 'CustoAI (Managed)'), ('BYOK', 'BYOK')],
default='CUSTOAI',
choices=[('BYOK', 'BYOK')],
default='BYOK',
max_length=16,
),
),
@@ -1,4 +1,4 @@
# SPEC-HUB-0027 §9, ADR-HUB-0037 §8 — этап 5, шаги 1-2.
# Провайдер LLM переезжает с канала на агента.
#
# BYOK-секрет переезжает с канала на агента. До сих пор credential_mode и model
# жили на AIAgent, а provider_integration — на Channel: одно решение было
@@ -15,7 +15,7 @@ def backfill_agents(apps, schema_editor):
channel=channel,
name=channel.name,
status="DRAFT",
credential_mode="CUSTOAI",
credential_mode="BYOK",
)
@@ -1,5 +1,4 @@
# ADR-CHATBALLS-0042 §3: managed-режим CustoAI удалён вместе с тарифным контуром.
# BYOK — единственный режим; поле credential_mode больше не нужно.
# ADR-CHATBALLS-0042 §3: BYOK — единственный режим; поле credential_mode больше не нужно.
from django.db import migrations
+5 -7
View File
@@ -6,7 +6,7 @@ from pgvector.django import VectorField
from chatballs.tenancy.models import TenantRelationModel
# Один основной агент на канал обработки (ADR-HUB-0019, ADR-CHATBALLS-0023).
# Один основной агент на канал обработки (ADR-CHATBALLS-0023).
DEFAULT_AI_MODEL = "anthropic/claude-sonnet-4.6"
@@ -40,8 +40,6 @@ class AIAgentStatus(models.TextChoices):
# Managed-режим CustoAI удалён вместе с тарифным контуром (ADR-CHATBALLS-0042 §3):
# AI работает только через провайдера организации (AIAgent.provider_integration).
@@ -151,7 +149,7 @@ class KnowledgeAttachment(TenantRelationModel):
# Непредсказуемый идентификатор публичной ссылки скачивания (ADR-CHATBALLS-0023):
# агент может отдать ссылку клиенту в мессенджер, где нет аутентификации Hub.
# агент может отдать ссылку клиенту в мессенджер, где нет аутентификации установки.
public_id = models.UUIDField(default=uuid.uuid4, unique=True, editable=False)
@@ -193,7 +191,7 @@ class KnowledgeAttachment(TenantRelationModel):
# Абсолютная ссылка скачивания: уходит клиентам в мессенджеры, поэтому
# строится от публичного адреса Hub, а не от request.
# строится от публичного адреса установки, а не от request.
from django.urls import reverse
@@ -343,7 +341,7 @@ class AIAgent(TenantRelationModel):
channel = models.OneToOneField("channels.Channel", on_delete=models.CASCADE, related_name="ai_agent")
# BYOK-секрет организации (SPEC-HUB-0027 §9). Раньше жил на Channel, из-за
# BYOK-секрет организации. Раньше жил на Channel, из-за
# чего credential_mode и model были на агенте, а секрет — на канале: одно
@@ -494,7 +492,7 @@ class LlmInvocation(TenantRelationModel):
tenant_relation_fields = ("channel",)
# Учёт по каналу (ADR-HUB-0019).
# Учёт по каналу.
channel = models.ForeignKey("channels.Channel", on_delete=models.SET_NULL, null=True, blank=True, related_name="ai_invocations")
@@ -1,10 +1,8 @@
"""Shared HTTP layer for OpenAI-compatible LLM providers (ADR-HUB-0033 §7,
ADR-CHATBALLS-0034 §3).
"""Shared HTTP layer for OpenAI-compatible LLM providers (ADR-CHATBALLS-0034 §3).
The OpenRouter, generic Custom and CustoAI (Yandex AI Studio) providers all
The OpenRouter and generic Custom providers both
speak the same Chat Completions shape:
@@ -20,7 +18,7 @@ speak the same Chat Completions shape:
This module owns the HTTP transport and response parsing so the three adapters
This module owns the HTTP transport and response parsing so the adapters
do not duplicate it. Adapters stay responsible for their own product semantics
@@ -38,17 +36,11 @@ import urllib.error
import urllib.request
from chatballs.ai.provider.base import (
ChatMessage,
ChatResult,
EmbeddingResult,
ProviderError,
ProviderRejected,
)
from chatballs.i18n import t
from chatballs.integrations.proxy import build_opener
@@ -8,7 +8,7 @@ class OpenRouterProvider(LLMProvider):
OpenAI Chat Completions shape with usage.include=true (returns the actual
USD cost in usage.cost). Delegates HTTP/parsing to the shared openai_http
layer (ADR-HUB-0033 §7, ADR-CHATBALLS-0034 §3); this adapter only carries the
layer (ADR-CHATBALLS-0034 §3); this adapter only carries the
OpenRouter product semantics (cost reporting). Exercised with a real key;
tests use the LocalProvider.
"""
@@ -38,10 +38,10 @@ def call_with_resilience(
sleep: Callable[[float], None] = time.sleep,
backoff: float = 0.5,
):
if breaker is not None:
breaker.before()
attempt = 0
while True:
if breaker is not None:
breaker.before()
try:
result = func()
except ProviderRejected:
@@ -49,10 +49,10 @@ def call_with_resilience(
# предохранитель тут ни при чём — сам провайдер жив и отвечает.
raise
except ProviderError:
if breaker is not None:
breaker.on_failure()
attempt += 1
if attempt > retries:
if breaker is not None:
breaker.on_failure()
raise
sleep(backoff * attempt)
continue
Loaded 100 of 418 files, more files were not shown because too many files have changed in this diff. Show more