Compare commits

..
18 Commits
Author SHA1 Message Date
Andrey 129e4a40ec 🔖 release: 1.8.0 2026-09-15 06:51:37 +03:00
AndreyandClaude Opus 5 9b54c544af ✅ test(cli): relay больше не профиль и не требует второго адреса
Проверка «веб и TURN на разных адресах» ушла вместе с самим правилом, проверка запуска стека теперь ждёт coturn среди обычных сервисов. doctor спрашивает про relay только на поднятом стеке: его запускают и до первого деплоя. Из исключения по bind-mount убран coturn — сертификатов он больше не монтирует.

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

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

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 06:38:47 +03:00
AndreyandClaude Opus 5 1de6239eb4 🐛 fix(chat): подсказка вкладок больше не наезжает на сами вкладки
Подсказка про «Общую очередь» и «На мне» использовала заметку ленты переписки, а у той отрицательный отступ сверху — текст затягивало на строку вкладок, и он ложился поверх названий. У подсказки списка теперь своя строка под вкладками с отступами и разделителем.

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

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

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

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

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

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

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

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

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

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

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

Исправить конфликт ширины кнопки создания метки со стилями меню. Выделить метки, заметку и план импорта в отдельные компоненты. Проверено: 13 backend-тестов, 7 frontend-тестов, сборка и браузерный предпросмотр файла из 66 документов.
2026-09-14 22:36:35 +03:00
Andrey 839a1fe815 🔖 release: 1.7.3 2026-09-14 17:34:03 +03:00
Andrey c62cb903ab 🐛 fix(gateway): подключать дополнительные сайты из постоянного тома 2026-09-14 17:34:03 +03:00
AndreyandClaude Fable 5.1 0bd76042d2 🔖 release: 1.7.2
Логотип организации в сайдбаре без обводки, крупные загрузчики системы и
портала, загрузчик портала сразу по центру, подпись «Работает на Chatballs»
в футере порталов.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-14 07:32:00 +03:00
AndreyandClaude Fable 5.1 c286ba7903 💄 fix(ui): логотип без обводки, крупные загрузчики, подпись «Работает на Chatballs»
Логотип организации в сайдбаре рисовался с рамкой и подложкой — снято,
знак стоит на прозрачном фоне.

Загрузчик портала до прихода манифеста и темы появлялся в левом верхнем
углу и только потом прыгал в центр: его стиль лежал в CSS портала, а этот
чанк грузится лениво, и первый кадр шёл без стилей. Стиль перенесён в
общий state.css, слой фиксирован на весь экран и центрирован независимо
от отступов документа. Загрузчик системы на экране входа в сессию и
загрузчик портала увеличены.

В футере всех шаблонов портала — подпись «Работает на Chatballs» со
ссылкой на chatballs.ru.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-14 07:31:50 +03:00
AndreyandClaude Opus 5 234a3e43da 📝 fix(i18n): убрать упоминания лимитов из текстов интерфейса
Тарифные лимиты сняты (ADR-CHATBALLS-0042), дневного бюджета в коде нет,
но два текста продолжали о них говорить.

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 06:34:03 +03:00
AndreyandClaude Opus 5 17b0c9387f 🔖 release: 1.7.1
Ход обновления виден в интерфейсе: шаги установки, время, итог с кнопкой
перезагрузки. Перезагрузка страницы во время установки больше не теряет ход.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 03:12:26 +03:00
AndreyandClaude Opus 5 27091c0234 ✨ feat(updates): ход обновления виден в интерфейсе
Установка перезапускает сам бэкенд, который о ней и рассказывает, — и до сих
пор человек об этом не узнавал: признак «идёт установка» жил в useRef и
исчезал при перезагрузке страницы, прогресса не было вовсе (одна строка
текста), а готовый результат карточка «Обновления» не показывала совсем.
Запустив обновление, человек оставался перед экраном без единого признака
того, что что-то происходит.

Теперь ход установки — отдельный блок: шаги (запуск, файл релиза, образы,
перезапуск) с отметками пройденного, текущий шаг с пульсом, номер шага и
время с начала. Молчание бэкенда — это и есть перезапуск, так он и подписан.
Метка установки живёт в sessionStorage, поэтому перезагрузка страницы
(в том числе во время лежащих сервисов) ход не теряет, а время считается от
момента запроса, а не от перезагрузки. Когда версия встала, карточка и полоса
показывают итог с кнопкой «Обновить страницу» — и молчат, если страница уже
открыта в новой версии. Прогресс появляется по нажатию кнопки, а не по ответу
сервера. Полоса наверху молчит, пока карточка «Обновления» на экране.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 03:12:17 +03:00
AndreyandClaude Opus 5 3c01486aa3 🐛 fix(presence): isinstance через union — на этом падал ruff в CI
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 02:47:09 +03:00
108 changed files with 3145 additions and 707 deletions

No files matched your search

+3
View File
@@ -51,6 +51,9 @@
import surfaces
}
# Дополнительные сайты из постоянного тома сохраняются при обновлении шлюза.
import /config/sites/*.caddy
# Любой хост, которому ask-эндпоинт разрешил сертификат.
https:// {
encode gzip zstd
+10 -9
View File
@@ -32,7 +32,7 @@
- [Step 2. First-run wizard](#step-2-first-run-wizard)
- [Step 3. Configure in the UI](#step-3-configure-in-the-ui)
- [Website widget](#website-widget)
- [Calls relay (optional)](#calls-relay-optional)
- [Calls](#calls)
- [External file storage (optional)](#external-file-storage-optional)
- [Updating](#updating)
- [Features](#features)
@@ -58,7 +58,7 @@ The platform installs on your own server with a single command. Customer data st
| **Server** | Linux, x86_64 |
| **Software** | Docker with the Docker Compose plugin |
| **Ports** | 80 and 443 open |
| **Calls relay (optional)** | A dedicated public IP, port 3478 and UDP range 49160–49999 |
| **Ports for calls** | 3478 (UDP and TCP) and the UDP range 49160–49999 |
A domain is not needed to start. The installation opens by the server's IP address; the domain is set later in the settings.
@@ -116,15 +116,16 @@ After creating a web widget, add one tag to your site:
The chat opens in an isolated window on top of the site.
### Calls relay (optional)
### Calls
Audio and video calls run directly between browsers. If customers or employees sit behind strict NAT or a corporate firewall, enable the TURN relay:
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.
```bash
COMPOSE_PROFILES=calls CHATBALLS_CALL_TURN_REALM=<domain> CHATBALLS_TURN_EXTERNAL_IP=<public IP> CHATBALLS_TURN_LISTENING_IP=<IP for TURN> docker compose up -d --wait
```
Open on the firewall:
The relay listens on a dedicated IP so that port 443 does not conflict with the web gateway. The certificate for TURN over TLS is placed in the directory set by `CHATBALLS_TURN_CERTS_DIR`. TURN addresses are then entered in **Settings → Communication**.
- 3478/udp and 3478/tcp — the relay itself;
- 49160–49999/udp — the conversation ports (two per call).
Networks that allow nothing but port 443 will not reach the relay on 3478. They need TURN over TLS on 443, which means a separate public address (443 on the main one belongs to the web gateway) or an external TURN service — its addresses go into the same settings.
### External file storage (optional)
@@ -270,7 +271,7 @@ docker compose logs worker
<details>
<summary><strong>Calls do not connect</strong></summary>
Between browsers a call goes directly. If one side is behind strict NAT, a relay is needed: enable the `calls` profile and enter the TURN addresses in **Settings**. Check that port 3478 and the UDP range 49160–49999 are open on the firewall. Make sure the relay listens on a separate IP and does not overlap with the web gateway on port 443.
Between browsers a call goes directly; behind strict NAT and on a VPN it goes through the relay. Check that the `coturn` container runs (`docker compose ps coturn`) and that 3478/udp, 3478/tcp and the 49160–49999/udp range are open on the firewall. The addresses in **Settings → TURN for calls** must not be empty: they are derived from the installation address, so that address has to be set first.
</details>
<details>
+10 -9
View File
@@ -32,7 +32,7 @@
- [Шаг 2. Мастер первого запуска](#шаг-2-мастер-первого-запуска)
- [Шаг 3. Настройка в интерфейсе](#шаг-3-настройка-в-интерфейсе)
- [Виджет на сайте](#виджет-на-сайте)
- [Звонки через relay (опционально)](#звонки-через-relay-опционально)
- [Звонки](#звонки)
- [Внешнее хранилище файлов (опционально)](#внешнее-хранилище-файлов-опционально)
- [Обновление](#обновление)
- [Функции](#функции)
@@ -58,7 +58,7 @@ Chatballs берёт на себя первую линию общения с к
| **Сервер** | Linux, x86_64 |
| **ПО** | Docker и плагин Docker Compose |
| **Порты** | 80 и 443 открыты |
| **Relay для звонков (опционально)** | Выделенный публичный IP, порт 3478 и диапазон UDP 49160–49999 |
| **Порты для звонков** | 3478 (UDP и TCP) и диапазон UDP 49160–49999 |
Домен на старте не нужен. Установка открывается по IP-адресу сервера, домен задаётся позже в настройках.
@@ -116,15 +116,16 @@ docker compose up -d --wait
Чат откроется в изолированном окне поверх сайта.
### Звонки через relay (опционально)
### Звонки
Аудио- и видеозвонки работают напрямую между браузерами. Если клиенты или сотрудники сидят за строгим NAT или корпоративным файрволом, включите TURN-relay:
Звонки работают сразу после установки. Между браузерами разговор идёт напрямую, а если одна из сторон за строгим NAT или в VPN — через relay, который поднимается вместе со стеком на том же адресе. Настраивать нечего: адреса relay появляются в **Настройки → TURN для звонков** сами, от адреса установки, и меняются только если вы ставите свой сервер.
```bash
COMPOSE_PROFILES=calls CHATBALLS_CALL_TURN_REALM=<домен> CHATBALLS_TURN_EXTERNAL_IP=<публичный IP> CHATBALLS_TURN_LISTENING_IP=<IP для TURN> docker compose up -d --wait
```
На файрволе нужно открыть:
Relay слушает выделенный IP, чтобы порт 443 не конфликтовал с веб-шлюзом. Сертификат для TURN-over-TLS кладётся в каталог, заданный переменной `CHATBALLS_TURN_CERTS_DIR`. Адреса TURN затем указываются в **Настройки → Коммуникации**.
- 3478/udp и 3478/tcp — сам relay;
- 49160–49999/udp — порты разговоров (по два на звонок).
Сети, где наружу разрешён только порт 443, relay на 3478 не пройдут. Для них нужен TURN-over-TLS на 443, а это отдельный публичный адрес (443 на основном занят веб-шлюзом) либо внешний TURN-сервис — его адреса вписываются в те же настройки.
### Внешнее хранилище файлов (опционально)
@@ -270,7 +271,7 @@ docker compose logs worker
<details>
<summary><strong>Звонки не соединяются</strong></summary>
Между браузерами звонок идёт напрямую. Если одна из сторон за строгим NAT, нужен relay: включите профиль `calls` и укажите адреса TURN в **Настройках**. Проверьте, что на файрволе открыты порт 3478 и диапазон UDP 49160–49999. Убедитесь, что relay слушает отдельный IP и не пересекается с веб-шлюзом по порту 443.
Между браузерами звонок идёт напрямую, за строгим NAT и в VPN — через relay. Проверьте, что контейнер `coturn` работает (`docker compose ps coturn`), а на файрволе открыты 3478/udp, 3478/tcp и диапазон 49160–49999/udp. В **Настройки → TURN для звонков** адреса должны быть непустыми: они строятся от адреса установки, поэтому сначала должен быть задан сам адрес.
</details>
<details>
+1 -1
View File
@@ -1 +1 @@
1.7.0
1.8.0
@@ -66,14 +66,11 @@ def _category_for_path(*, context: TenantContext, raw_path: object) -> Knowledge
for raw_name in raw_path:
if not isinstance(raw_name, str) or not raw_name.strip():
raise ValidationError({"categoryPath": t("ai.category_names_strings")})
try:
category = KnowledgeCategory.objects.get(
organization_id=context.organization_id,
parent_id=parent_id,
name=raw_name.strip(),
)
except KnowledgeCategory.DoesNotExist as error:
raise ValidationError({"categoryPath": t("ai.category_path_not_found")}) from error
category, _ = KnowledgeCategory.objects.get_or_create(
organization_id=context.organization_id,
parent_id=parent_id,
name=raw_name.strip(),
)
parent_id = category.id
assert category is not None
return category
@@ -65,7 +65,7 @@ class KnowledgeMetadataImportTests(TestCase):
knowledge = Knowledge.objects.get(title="Acme support")
self.assertEqual(knowledge.category_id, self.app.id)
def test_unknown_category_path_fails_per_document(self) -> None:
def test_missing_category_is_created_under_existing_parent(self) -> None:
response = self._import(
[
{
@@ -78,15 +78,52 @@ class KnowledgeMetadataImportTests(TestCase):
)
payload = response.json()
self.assertEqual(payload["created"], 1)
self.assertEqual(len(payload["failed"]), 1)
self.assertFalse(Knowledge.objects.filter(title="Unknown path").exists())
self.assertFalse(
KnowledgeCategory.objects.filter(
organization=self.organization,
name="Missing",
).exists()
self.assertEqual(payload["created"], 2)
self.assertEqual(payload["failed"], [])
category = KnowledgeCategory.objects.get(
organization=self.organization, parent=self.products, name="Missing"
)
self.assertEqual(Knowledge.objects.get(title="Unknown path").category_id, category.id)
def test_new_tree_is_shared_and_repeat_import_does_not_duplicate_categories(self) -> None:
documents = [
{"title": title, "content": "Text", "categoryPath": [" New root ", "Child", "Leaf"]}
for title in ["First", "Second"]
]
before = KnowledgeCategory.objects.count()
self.assertEqual(self._import(documents).json()["created"], 2)
self.assertEqual(self._import(documents).json()["unchanged"], 2)
self.assertEqual(KnowledgeCategory.objects.count(), before + 3)
first = Knowledge.objects.get(title="First")
second = Knowledge.objects.get(title="Second")
self.assertEqual(first.category_id, second.category_id)
self.assertEqual(first.category.parent.parent.name, "New root")
def test_failed_document_rolls_back_new_categories_and_other_documents_import(self) -> None:
response = self._import([
{"title": "Invalid", "content": "Text", "description": [],
"categoryPath": ["Rollback root", "Child"]},
{"title": "Valid sibling", "content": "Text", "categoryPath": ["Kept root"]},
])
self.assertEqual(response.json()["created"], 1)
self.assertEqual(len(response.json()["failed"]), 1)
self.assertFalse(KnowledgeCategory.objects.filter(name="Rollback root").exists())
self.assertFalse(Knowledge.objects.filter(title="Invalid").exists())
def test_invalid_path_rolls_back_preceding_levels(self) -> None:
for path in [["Invalid root", " "], ["Invalid root", 42], []]:
with self.subTest(path=path):
response = self._import([{"title": "Invalid path", "content": "Text", "categoryPath": path}])
self.assertEqual(len(response.json()["failed"]), 1)
self.assertFalse(KnowledgeCategory.objects.filter(name="Invalid root").exists())
def test_existing_document_moves_to_new_category(self) -> None:
self._import([{"title": "Moving", "content": "Text"}])
response = self._import([
{"title": "Moving", "content": "Text", "categoryPath": ["New destination"]}
])
self.assertEqual(response.json()["updated"], 1)
self.assertEqual(Knowledge.objects.get(title="Moving").category.name, "New destination")
def test_omitted_metadata_preserves_category_and_agent_links(self) -> None:
self._import(
@@ -157,6 +194,7 @@ class KnowledgeImportPolicyTests(KnowledgePolicyTestBase):
{
"title": self.support_only.title,
"content": "Attempted overwrite",
"categoryPath": ["Forbidden category"],
}
]
}
@@ -167,3 +205,4 @@ class KnowledgeImportPolicyTests(KnowledgePolicyTestBase):
self.assertEqual(response.status_code, 403)
self.support_only.refresh_from_db()
self.assertEqual(self.support_only.content, "")
self.assertFalse(KnowledgeCategory.objects.filter(name="Forbidden category").exists())
+4 -3
View File
@@ -2,7 +2,7 @@ from django.conf import settings
from chatballs.calls.models import CallSession
from chatballs.calls.turn import turn_credentials
from chatballs.identity.instance_settings import turn_config
from chatballs.identity.instance_settings import default_stun_urls, turn_config
def _iso(value):
@@ -67,8 +67,9 @@ def ice_servers_payload() -> list[dict]:
# fallback с краткоживущими credentials. Генерируется на каждый запрос токена,
# поэтому клиент всегда получает не истёкшие TURN credentials.
servers: list[dict] = []
if settings.CHATBALLS_CALL_STUN_URLS:
servers.append({"urls": list(settings.CHATBALLS_CALL_STUN_URLS)})
stun_urls = list(settings.CHATBALLS_CALL_STUN_URLS) or default_stun_urls()
if stun_urls:
servers.append({"urls": stun_urls})
turn_urls, ttl = turn_config()
if turn_urls and settings.CHATBALLS_CALL_TURN_SECRET:
username, credential = turn_credentials(ttl_seconds=ttl)
+13 -7
View File
@@ -13,7 +13,7 @@ from chatballs.calls.errors import (
CallInvalidTransition,
CallTokenError,
)
from chatballs.calls.lifecycle import transition_call
from chatballs.calls.lifecycle import finish_call, transition_call
from chatballs.calls.metrics import record_call_metric
from chatballs.calls.models import (
TERMINAL_CALL_STATUSES,
@@ -231,6 +231,15 @@ def issue_staff_access_token(*, context: TenantContext, call_session: CallSessio
def cancel_call(*, context: TenantContext, call_session: CallSession) -> CallSession:
"""Оператор закончил звонок — из любой фазы, в которой тот ещё жив.
Кнопка у оператора одна и означает «прекратить»: до ответа клиента это
отмена, после — завершение. Раньше здесь был только переход в CANCELLED, и
он разрешён лишь из REQUESTED/RINGING: клиент принял звонок, соединение не
установилось (частый случай за NAT), оператор жмёт «завершить» — и получает
409, а звонок остаётся висеть. Фазу выбирает `finish_call`, тот же код, что
и у клиента.
"""
user = context.actor_user
if user is None or context.membership is None:
raise CallAccessDenied(t("calls.operator_context_required"))
@@ -238,12 +247,9 @@ def cancel_call(*, context: TenantContext, call_session: CallSession) -> CallSes
if not call_session.participants.filter(side=ParticipantSide.STAFF, user=user).exists():
raise CallConflict(t("calls.not_a_participant"))
try:
# Повторная отмена идемпотентна: transition_call вернёт звонок без изменений.
return transition_call(
call_session_id=call_session.id,
target_status=CallStatus.CANCELLED,
ended_by=CallEndedBy.STAFF,
)
# Повторный вызов идемпотентен: у завершённого звонка finish_call
# возвращает его как есть.
return finish_call(call_session_id=call_session.id, side=ParticipantSide.STAFF)
except CallInvalidTransition as error:
raise CallConflict(t("calls.cannot_cancel")) from error
@@ -16,6 +16,7 @@ from chatballs.calls.models import (
CallStatus,
InviteDeliveryStatus,
)
from chatballs.calls.lifecycle import transition_call
from chatballs.calls.services import (
decline_call_for_identity,
open_call_for_identity,
@@ -61,6 +62,22 @@ class CancelCallApiTests(CallTestCase):
1,
)
def test_operator_ends_call_the_customer_already_accepted(self) -> None:
# Клиент принял звонок, соединение не установилось (частый случай за
# NAT). Кнопка оператора одна и обязана закончить звонок, а не упереться
# в запрет перехода.
created = create_call_request(conversation_id=self.conversation.id, initiator=self.owner)
open_call_for_identity(identity=self.identity)
transition_call(call_session_id=created.call_session.id, target_status=CallStatus.ACCEPTED)
response = self.client.post(f"/api/v1/calls/{created.call_session.id}/cancel/")
self.assertEqual(response.status_code, 200)
created.call_session.refresh_from_db()
self.assertEqual(created.call_session.status, CallStatus.FAILED)
self.assertEqual(created.call_session.failure_code, "ABORTED_BEFORE_CONNECT")
self.assertEqual(created.call_session.ended_by, CallEndedBy.STAFF)
def test_non_participant_cannot_cancel(self) -> None:
created = create_call_request(conversation_id=self.conversation.id, initiator=self.owner)
self.client.force_authenticate(user=self.operator)
@@ -3,10 +3,11 @@ import hashlib
import hmac
import time
from django.test import SimpleTestCase, override_settings
from django.test import SimpleTestCase, TestCase, override_settings
from chatballs.calls.serializers import ice_servers_payload
from chatballs.calls.turn import turn_credentials
from chatballs.identity.instance_settings import InstanceSettings, turn_config
SECRET = "coturn-shared-secret"
TURN_URLS = ["turn:example.com:3478?transport=udp", "turn:example.com:3478?transport=tcp"]
@@ -71,3 +72,56 @@ class IceServersPayloadTests(SimpleTestCase):
def test_turn_urls_without_secret_are_not_exposed(self) -> None:
# Без секрета выдать рабочие credentials нельзя — TURN не отдаётся вовсе.
self.assertEqual(ice_servers_payload(), [])
@override_settings(
CHATBALLS_CALL_STUN_URLS=[],
CHATBALLS_CALL_TURN_URLS=[],
CHATBALLS_CALL_TURN_SECRET=SECRET,
CHATBALLS_CALL_TURN_TTL_SECONDS=3600,
)
class TurnDefaultsTests(TestCase):
"""Relay коробки: адреса берутся от адреса установки, без единой настройки."""
def _set_host(self, host: str) -> None:
row = InstanceSettings.load()
row.public_host = host
row.save()
def test_addresses_come_from_the_installation_address(self) -> None:
self._set_host("crm.example.com")
urls, _ttl = turn_config()
self.assertEqual(
urls,
[
"turn:crm.example.com:3478?transport=udp",
"turn:crm.example.com:3478?transport=tcp",
],
)
def test_owner_addresses_win_over_the_defaults(self) -> None:
row = InstanceSettings.load()
row.public_host = "crm.example.com"
row.turn_urls = "turns:turn.example.net:5349?transport=tcp"
row.save()
urls, _ttl = turn_config()
self.assertEqual(urls, ["turns:turn.example.net:5349?transport=tcp"])
def test_ice_payload_offers_stun_and_turn_of_the_box(self) -> None:
self._set_host("crm.example.com")
servers = ice_servers_payload()
self.assertEqual(servers[0], {"urls": ["stun:crm.example.com:3478"]})
self.assertEqual(
servers[1]["urls"],
[
"turn:crm.example.com:3478?transport=udp",
"turn:crm.example.com:3478?transport=tcp",
],
)
self.assertEqual(
servers[1]["credential"], _expected_credential(servers[1]["username"])
)
def test_without_the_installation_address_there_is_nothing_to_offer(self) -> None:
self._set_host("")
self.assertEqual(ice_servers_payload(), [])
@@ -26,6 +26,7 @@ from chatballs.conversations.models import (
Conversation,
LifecycleState,
)
from chatballs.conversations.contact_avatars import contact_avatar_url_in
from chatballs.i18n import t
from chatballs.identity.audit_catalog import (
audit_action_label,
@@ -161,7 +162,7 @@ def client_row(contact: Contact) -> dict:
# на другом языке это перестало бы работать.
"isGuest": not contact.name,
"phone": contact.phone,
"avatarUrl": contact.avatar_url,
"avatarUrl": contact_avatar_url_in(contact, contact.organization_id),
"email": next(
(
identity.external_user_id
@@ -301,7 +302,7 @@ def client_detail(organization_id: int, contact_id: int) -> dict:
# на другом языке это перестало бы работать.
"isGuest": not contact.name,
"phone": contact.phone,
"avatarUrl": contact.avatar_url,
"avatarUrl": contact_avatar_url_in(contact, contact.organization_id),
# Поля карточки из чата (описание, компания, город).
"description": contact.description,
"company": contact.company,
@@ -370,7 +371,7 @@ def _duplicate_candidate(organization_id: int, contact: Contact) -> dict | None:
"cid": f"CUS-{other.id}",
"name": other.name or t("conversations.guest"),
"isGuest": not other.name,
"avatarUrl": other.avatar_url,
"avatarUrl": contact_avatar_url_in(other, other.organization_id),
"dialogs": other.conversations.count(),
"sources": sorted({identity.connection.provider for identity in identities}),
"phone": other.phone,
@@ -0,0 +1,87 @@
"""Фото контакта: скачиваем у провайдера и отдаём со своего адреса.
Рабочее место живёт под `Content-Security-Policy: img-src 'self'`, поэтому
ссылка на CDN мессенджера до экрана не доезжает — оператор видит инициалы
вместо лица. Значит, фото должно лежать у нас и отдаваться тенантным
эндпоинтом, как фото сотрудника (`identity.avatars`).
Источник фото запоминается в `Contact.avatar_source`: у MAX это адрес из
профиля отправителя, у Telegram — идентификатор файла фотографии. Пока
источник тот же, повторно ничего не качается. Строка `tg:none` означает «у
человека в Telegram фото нет»: без неё каждое его сообщение стоило бы лишнего
запроса к API.
"""
from __future__ import annotations
import hashlib
import logging
from django.core.files.base import ContentFile
from chatballs.conversations.models import Contact
from chatballs.identity.avatars import image_type, organization_public_id
logger = logging.getLogger(__name__)
MAX_AVATAR_BYTES = 2 * 1024 * 1024
# Проверено и фото нет: помним, чтобы не спрашивать провайдера снова.
NO_AVATAR = "none"
def contact_avatar_url_in(contact: Contact | None, organization_id: int) -> str | None:
"""Ссылка на фото контакта для рабочего места; None — фото нет."""
if contact is None:
return None
if contact.avatar:
version = hashlib.sha1(contact.avatar.name.encode("utf-8")).hexdigest()[:8]
public_id = organization_public_id(organization_id)
return f"/api/v1/organizations/{public_id}/conversations/clients/{contact.id}/avatar/?v={version}"
# Демо-набор и старые записи держат ссылку на наш же адрес — она рабочая.
return contact.avatar_url or None
def store_contact_avatar(contact: Contact, *, content: bytes, source: str) -> bool:
"""Сохранить скачанное фото. False — это не картинка или она слишком велика."""
if not content or len(content) > MAX_AVATAR_BYTES:
return False
detected = image_type(content)
if detected is None:
return False
content_type, suffix = detected
if contact.avatar:
contact.avatar.delete(save=False)
contact.avatar.save(f"avatar{suffix}", ContentFile(content), save=False)
contact.avatar_content_type = content_type
contact.avatar_source = source[:512]
contact.save(update_fields=["avatar", "avatar_content_type", "avatar_source"])
return True
def _checked_marker(source: str) -> str:
"""«Фото по этому источнику спрашивали, его нет» — чтобы не спрашивать снова."""
return f"{NO_AVATAR}:{source}"[:512]
def refresh_contact_avatar(integration, inbound, contact: Contact) -> None:
"""Подтянуть фото отправителя, если провайдер его отдаёт и оно новое.
Сбой скачивания не мешает сообщению: фото — украшение карточки, а не её
содержание.
"""
from chatballs.conversations import transports
try:
source = transports.avatar_source(integration, inbound)
if not source or contact.avatar_source in (source, _checked_marker(source)):
return
fetched = transports.download_avatar(integration, inbound)
if fetched is None:
contact.avatar_source = _checked_marker(source)
contact.save(update_fields=["avatar_source"])
return
content, source_key = fetched
if not store_contact_avatar(contact, content=content, source=source_key):
logger.info("Contact %s avatar from %s is not an image", contact.id, source_key)
except Exception as error: # noqa: BLE001 - провайдер/сеть, деградация мягкая
logger.info("Contact %s avatar download failed: %s", contact.id, error)
+19 -3
View File
@@ -17,6 +17,7 @@ from chatballs.ai.provider.base import ProviderError
from chatballs.ai.runtime import HANDOFF_TOKEN
from chatballs.channels.runtime import run_channel_turn
from chatballs.conversations import transports
from chatballs.conversations.contact_avatars import refresh_contact_avatar
from chatballs.conversations.models import (
ConnectionIdentity,
Contact,
@@ -177,13 +178,27 @@ def ingest_inbound(integration, inbound: InboundMessage) -> None:
context = TenantContext.for_resource(channel.organization)
agent = getattr(channel, "ai_agent", None)
ai_available = bool(agent and agent.is_active)
if not ai_available:
# Частая причина «диалог сразу ждёт оператора»: у канала подключения нет
# агента или он не активен. В журнале это должно быть видно одной
# строкой, иначе настройку ищут перебором.
logger.info(
"Channel %s has no active AI agent (agent=%s) — conversation goes to the operator queue",
channel.id,
getattr(agent, "status", None),
)
source = f"{integration.provider.lower()}:{integration.id}"
if _already_processed(context, source, inbound.external_id, inbound.text):
return
# Явный шаринг контакта: сообщение без текста, но с телефоном.
is_contact_share = bool(inbound.phone)
is_voice = bool(inbound.voice_file_id or inbound.voice_url or inbound.voice_content)
is_voice = bool(
inbound.voice_file_id
or inbound.voice_url
or inbound.voice_content
or inbound.voice_unavailable
)
files = tuple(inbound.files or ())
# Файлы без текста: сообщение-контейнер не создаём, каждый файл — своя реплика.
files_only = bool(files) and not inbound.text and not is_contact_share and not is_voice
@@ -224,11 +239,12 @@ def ingest_inbound(integration, inbound: InboundMessage) -> None:
if identity.phone_verified_at is None:
identity.phone_verified_at = timezone.now()
identity.save(update_fields=["phone_verified_at"])
# Аватар обновляем при каждом заходе: провайдер может сменить фото,
# а контакт ещё не шарил телефон (is_contact_share=False).
# Адрес фото у провайдера храним как было, но показываем оператору не
# его: страница под CSP `img-src 'self'` чужую картинку не покажет.
if inbound.avatar_url and contact.avatar_url != inbound.avatar_url:
contact.avatar_url = inbound.avatar_url
contact.save(update_fields=["avatar_url"])
refresh_contact_avatar(integration, inbound, contact)
conversation = (
Conversation.objects.filter(channel=channel, contact=contact, lifecycle=LifecycleState.OPEN)
@@ -0,0 +1,29 @@
# Generated by Django 5.2.16 on 2026-09-14 21:32
import chatballs.conversations.models
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('conversations', '0024_queue_policy_author'),
]
operations = [
migrations.AddField(
model_name='contact',
name='avatar',
field=models.FileField(blank=True, default='', max_length=512, upload_to=chatballs.conversations.models.contact_avatar_upload_path),
),
migrations.AddField(
model_name='contact',
name='avatar_content_type',
field=models.CharField(blank=True, default='', max_length=64),
),
migrations.AddField(
model_name='contact',
name='avatar_source',
field=models.CharField(blank=True, default='', max_length=512),
),
]
@@ -11,6 +11,14 @@ from chatballs.tenancy.models import TenantRelationModel
# разделено на независимые оси; перехват оператором — атомарный.
def contact_avatar_upload_path(instance: "Contact", filename: str) -> str:
import uuid
from pathlib import Path
suffix = Path(filename).suffix.lower()[:8] or ".jpg"
return f"organizations/{instance.organization.public_id}/contacts/{uuid.uuid4().hex}{suffix}"
class Contact(models.Model):
organization = models.ForeignKey("identity.Organization", on_delete=models.PROTECT, related_name="contacts")
name = models.CharField(max_length=255, blank=True)
@@ -22,6 +30,16 @@ class Contact(models.Model):
# getUpdates, поэтому для него поле остаётся пустым. Хранится только URL —
# само изображение живёт на стороне провайдера.
avatar_url = models.URLField(max_length=512, blank=True, default="")
# Фото контакта, скачанное у провайдера и лежащее у нас. Внешней ссылкой
# обойтись нельзя: страница рабочего места живёт под CSP `img-src 'self'`,
# и картинка с чужого домена до экрана не доезжает — оператор видит
# инициалы вместо фото. Источник запоминается, чтобы не качать то же самое
# на каждое сообщение.
avatar = models.FileField(
upload_to=contact_avatar_upload_path, max_length=512, blank=True, default=""
)
avatar_content_type = models.CharField(max_length=64, blank=True, default="")
avatar_source = models.CharField(max_length=512, blank=True, default="")
# Карточка контакта (дизайн-базлайн v2, решение 5): описание, компания, город —
# заполняет оператор.
description = models.TextField(blank=True, default="")
@@ -1,4 +1,5 @@
from django.core.exceptions import ValidationError
from django.http import FileResponse
from rest_framework.request import Request
from rest_framework.response import Response
@@ -21,6 +22,29 @@ class ConversationStatsView(ConversationViewBase):
return Response(sales_overview_stats(request.tenant_context, period))
class ContactAvatarView(ConversationViewBase):
"""Фото контакта, скачанное у провайдера.
Отдаётся со своего адреса: страница рабочего места живёт под CSP
`img-src 'self'`, и ссылка на CDN мессенджера до экрана не доезжает.
Видно тому же, кто видит диалоги, — фото клиента и есть часть карточки.
"""
def get(self, request: Request, contact_id: int):
contact = Contact.objects.filter(
organization=self._org(request), id=contact_id
).first()
if contact is None or not contact.avatar:
return Response({"detail": t("sales.client_not_found")}, status=404)
response = FileResponse(
contact.avatar.open("rb"),
content_type=contact.avatar_content_type or "application/octet-stream",
filename="avatar",
)
response["Cache-Control"] = "private, max-age=86400"
return response
class ClientsView(ConversationViewBase):
required_capability = "customers.view"
@@ -8,6 +8,7 @@ from chatballs.conversations.models import (
MessageKind,
)
from chatballs.i18n import t
from chatballs.conversations.contact_avatars import contact_avatar_url_in
from chatballs.identity.avatars import user_avatar_url_in
from chatballs.integrations.features import features_payload
from chatballs.integrations.models import IntegrationProvider
@@ -228,7 +229,9 @@ def conversation_payload(
"id": conversation.contact_id,
"name": conversation.contact.name,
"phone": conversation.contact.phone,
"avatarUrl": conversation.contact.avatar_url,
"avatarUrl": contact_avatar_url_in(
conversation.contact, conversation.organization_id
),
"description": conversation.contact.description,
"company": conversation.contact.company,
"city": conversation.contact.city,
@@ -14,10 +14,15 @@ from chatballs.conversations.models import (
SystemEvent,
)
from chatballs.conversations.queue import QUEUE_FIELDS, enter_queue, leave_queue
from chatballs.conversations.realtime import notify_conversation_changed
from chatballs.i18n import customer_language, t
from chatballs.identity.models import EmployeeRole
from chatballs.integrations.models import IntegrationProvider
from chatballs.notifications.models import NotificationAudience, NotificationType
from chatballs.notifications.models import (
Notification,
NotificationAudience,
NotificationType,
)
from chatballs.notifications.services import notify
from chatballs.tenancy.context import TenantContext
@@ -463,3 +468,41 @@ def assign_operator(*, context: TenantContext, conversation_id: int, assignee) -
dedup_key=f"assign:{conversation.id}:{assignee.pk}",
)
return conversation
@transaction.atomic
def delete_conversation(*, context: TenantContext, conversation: Conversation) -> None:
"""Удалить диалог насовсем: переписку, вложения, звонки и оклики о нём.
Раньше «удалить» означало архив: диалог пропадал из списков, но продолжал
жить — в него приходили сообщения из канала, он поднимал уведомления и
возвращался клиенту в виджете как ни в чём не бывало. Решение владельца
2026-09-14: удалён — значит удалён. Права на это есть только у владельца и
администратора (проверяет представление).
Файлы удаляются явно: `FileField` при удалении строки оставляет их на
диске, а «удалён» не должно означать «лежит в media».
"""
conversation_id = conversation.id
organization_id = conversation.organization_id
for message in conversation.messages.exclude(audio="", attachment=""):
if message.audio:
message.audio.delete(save=False)
if message.attachment:
message.attachment.delete(save=False)
# Оклик ведёт в диалог, которого больше нет: и в списке уведомлений, и в
# мессенджере сотрудника такая строка — тупик.
Notification.objects.filter(
organization_id=organization_id,
source_type="Conversation",
source_id=str(conversation_id),
).delete()
# Звонки держат диалог внешним ключом PROTECT; история звонка без самого
# диалога ничего не значит, поэтому уходит вместе с ним.
from chatballs.calls.models import CallSession
CallSession.objects.filter(conversation_id=conversation_id).delete()
conversation.delete()
# Открытые рабочие места узнают об этом событием: у того, кто держал диалог
# открытым, он должен закрыться, а не висеть мёртвой карточкой.
notify_conversation_changed(conversation_id, organization_id=organization_id)
+18 -2
View File
@@ -20,6 +20,7 @@ from chatballs.conversations.models import (
LifecycleState,
Message,
)
from chatballs.conversations.selectors import apply_conversation_visibility
from chatballs.i18n import t
_ACTIVE_WINDOW = timedelta(minutes=15)
@@ -68,12 +69,27 @@ def sales_overview_stats(context, period: str) -> dict:
now = timezone.now()
start, prev_start = _window(period, now)
open_qs = Conversation.objects.filter(organization_id=organization_id, lifecycle=LifecycleState.OPEN)
# Удалённые по-старому (архивные) диалоги в живых числах не участвуют: они
# не видны ни в одном списке, а бейдж из-за них не опускался до нуля.
open_qs = Conversation.objects.filter(
organization_id=organization_id,
lifecycle=LifecycleState.OPEN,
archived_at__isnull=True,
)
open_dialogs = open_qs.count()
# «Ждут оператора» = очередь: диалоги, которые никто не взял (PAUSED).
# Взятые оператором (HUMAN), но ещё без ответа, очередью не считаются —
# иначе бейдж «Диалоги» показывает число при полностью разобранном inbox.
waiting = open_qs.filter(control_mode=ControlMode.PAUSED).count()
#
# Это же число висит бейджем на пункте «Чат», поэтому считается по границе
# видимости сотрудника: иначе он видит счётчик диалогов, которых не увидит,
# и разобрать его не может (ADR-CHATBALLS-0043 §4). Счётчики вкладок списка
# (chat_extras_views) считают ровно так же.
waiting = (
apply_conversation_visibility(open_qs, context)
.filter(control_mode=ControlMode.PAUSED)
.count()
)
ops = {
"openDialogs": open_dialogs,
"activeNow": open_qs.filter(last_activity_at__gte=now - _ACTIVE_WINDOW).count(),
@@ -0,0 +1,137 @@
"""Фото контакта из мессенджера: скачивается один раз и отдаётся со своего адреса."""
from unittest import mock
from django.test import TestCase
from chatballs.channels.models import Channel
from chatballs.conversations.contact_avatars import (
contact_avatar_url_in,
refresh_contact_avatar,
)
from chatballs.conversations.models import Contact
from chatballs.conversations.transports.base import InboundMessage
from chatballs.identity.bootstrap import bootstrap_owner
from chatballs.identity.models import Organization
from chatballs.integrations.models import (
Integration,
IntegrationKind,
IntegrationProvider,
)
from chatballs.tenancy.database import tenant_atomic
from chatballs.testing import TenantAPIClient as APIClient
PNG = b"\x89PNG\r\n\x1a\n" + b"0" * 64
class ContactAvatarTests(TestCase):
def setUp(self) -> None:
bootstrap_owner(email="owner@example.com", password="temporary-password")
self.organization = Organization.objects.get(slug="demo")
self.channel = Channel.objects.create(
organization=self.organization, code="line", name="Линия"
)
self.contact = Contact.objects.create(
organization=self.organization, name="Иван"
)
self.inbound = InboundMessage(
external_id="1",
user_id="777",
chat_id="777",
text="привет",
display_name="Иван",
)
def _integration(self, provider: str) -> Integration:
return Integration.objects.create(
organization=self.organization,
kind=IntegrationKind.MESSENGER,
provider=provider,
name=provider,
secret="token",
channel=self.channel,
)
def test_telegram_photo_is_asked_once_and_stored(self) -> None:
integration = self._integration(IntegrationProvider.TELEGRAM)
with mock.patch(
"chatballs.conversations.transports.telegram.download_profile_photo",
return_value=PNG,
) as download:
with tenant_atomic(self.organization.id):
refresh_contact_avatar(integration, self.inbound, self.contact)
with tenant_atomic(self.organization.id):
refresh_contact_avatar(integration, self.inbound, self.contact)
self.assertEqual(download.call_count, 1)
self.contact.refresh_from_db()
self.assertTrue(self.contact.avatar)
self.assertEqual(self.contact.avatar_content_type, "image/png")
self.assertEqual(self.contact.avatar_source, "tg:777")
def test_telegram_without_photo_is_not_asked_again(self) -> None:
integration = self._integration(IntegrationProvider.TELEGRAM)
with mock.patch(
"chatballs.conversations.transports.telegram.download_profile_photo",
return_value=None,
) as download:
with tenant_atomic(self.organization.id):
refresh_contact_avatar(integration, self.inbound, self.contact)
with tenant_atomic(self.organization.id):
refresh_contact_avatar(integration, self.inbound, self.contact)
self.assertEqual(download.call_count, 1)
self.contact.refresh_from_db()
self.assertFalse(self.contact.avatar)
def test_max_photo_follows_the_url_from_the_update(self) -> None:
integration = self._integration(IntegrationProvider.MAX)
inbound = InboundMessage(
external_id="1",
user_id="777",
chat_id="",
text="привет",
display_name="Иван",
avatar_url="https://cdn.example.test/ivan.png",
)
with mock.patch(
"chatballs.conversations.transports.max.download_file",
return_value=(PNG, "image/png"),
) as download:
with tenant_atomic(self.organization.id):
refresh_contact_avatar(integration, inbound, self.contact)
with tenant_atomic(self.organization.id):
refresh_contact_avatar(integration, inbound, self.contact)
self.assertEqual(download.call_count, 1)
self.contact.refresh_from_db()
self.assertEqual(self.contact.avatar_source, "https://cdn.example.test/ivan.png")
def test_download_failure_leaves_the_contact_alone(self) -> None:
integration = self._integration(IntegrationProvider.TELEGRAM)
with mock.patch(
"chatballs.conversations.transports.telegram.download_profile_photo",
side_effect=OSError("network is down"),
), tenant_atomic(self.organization.id):
refresh_contact_avatar(integration, self.inbound, self.contact)
self.contact.refresh_from_db()
self.assertFalse(self.contact.avatar)
self.assertEqual(self.contact.avatar_source, "")
def test_stored_photo_is_served_from_our_own_address(self) -> None:
integration = self._integration(IntegrationProvider.TELEGRAM)
with mock.patch(
"chatballs.conversations.transports.telegram.download_profile_photo",
return_value=PNG,
), tenant_atomic(self.organization.id):
refresh_contact_avatar(integration, self.inbound, self.contact)
self.contact.refresh_from_db()
url = contact_avatar_url_in(self.contact, self.organization.id)
self.assertIn(f"/conversations/clients/{self.contact.id}/avatar/", url)
client = APIClient()
client.login(username="owner@example.com", password="temporary-password")
response = client.get(f"/api/v1/conversations/clients/{self.contact.id}/avatar/")
self.assertEqual(response.status_code, 200)
self.assertEqual(response.headers["Content-Type"], "image/png")
self.assertEqual(b"".join(response.streaming_content), PNG)
def test_contact_without_photo_has_no_url(self) -> None:
self.assertIsNone(contact_avatar_url_in(self.contact, self.organization.id))
@@ -0,0 +1,105 @@
"""Удаление диалога: право, полнота и последствия для виджета.
Решение владельца 2026-09-14: «удалить» означает удалить, а не отправить в
архив. Удалённый диалог не принимает сообщений, не окликает уведомлениями и
обнуляет переписку в виджете клиента.
"""
import json
from django.test import TestCase
from chatballs.channels.models import Channel
from chatballs.conversations.models import (
Contact,
Conversation,
Message,
MessageAuthor,
)
from chatballs.identity.group_models import EmployeeGroup, EmployeeGroupMember
from chatballs.identity.models import (
EmployeeRole,
HumanUser,
Organization,
OrganizationMembership,
)
from chatballs.notifications.models import Notification, NotificationType
from chatballs.testing import TenantAPIClient as APIClient
class ConversationDeleteTests(TestCase):
def setUp(self) -> None:
self.organization = Organization.objects.create(name="Chat", slug="chat-delete")
self.owner = self._member("owner@delete.test", EmployeeRole.OWNER)
self.employee = self._member("employee@delete.test", EmployeeRole.EMPLOYEE)
group = EmployeeGroup.objects.create(
organization=self.organization, name="Операторы"
)
EmployeeGroupMember.objects.create(
organization=self.organization, group=group, employee=self.employee
)
self.channel = Channel.objects.create(
organization=self.organization, code="line", name="Линия"
)
contact = Contact.objects.create(organization=self.organization, name="Иван")
self.conversation = Conversation.objects.create(
organization=self.organization, channel=self.channel, contact=contact
)
self.message = Message.objects.create(
conversation=self.conversation,
author_type=MessageAuthor.CONTACT,
text="Здравствуйте",
)
self.owner_client = APIClient()
self.owner_client.force_authenticate(self.owner.user)
self.employee_client = APIClient()
self.employee_client.force_authenticate(self.employee.user)
def _member(self, email: str, role: str) -> OrganizationMembership:
user = HumanUser.objects.create_user(email=email, password="Password-123")
return OrganizationMembership.objects.create(
user=user,
organization=self.organization,
role=role,
position_title="Specialist",
)
def _url(self) -> str:
return f"/api/v1/conversations/{self.conversation.id}/"
def test_operator_cannot_delete(self) -> None:
response = self.employee_client.delete(self._url())
self.assertEqual(response.status_code, 403)
self.assertTrue(Conversation.objects.filter(id=self.conversation.id).exists())
def test_owner_deletes_conversation_with_history(self) -> None:
response = self.owner_client.delete(self._url())
self.assertEqual(response.status_code, 204)
self.assertFalse(Conversation.objects.filter(id=self.conversation.id).exists())
self.assertFalse(Message.objects.filter(id=self.message.id).exists())
def test_notifications_about_conversation_are_gone(self) -> None:
Notification.objects.create(
organization=self.organization,
type=NotificationType.NEW_DIALOG,
title="Новый диалог",
target_route="chat",
target_id=str(self.conversation.id),
source_type="Conversation",
source_id=str(self.conversation.id),
)
self.owner_client.delete(self._url())
self.assertEqual(Notification.objects.count(), 0)
def test_deleted_conversation_cannot_be_written_to(self) -> None:
self.owner_client.delete(self._url())
response = self.owner_client.post(
f"{self._url()}messages/",
data=json.dumps({"text": "ещё раз"}),
content_type="application/json",
)
self.assertEqual(response.status_code, 404)
def test_missing_conversation_is_not_found(self) -> None:
self.owner_client.delete(self._url())
self.assertEqual(self.owner_client.delete(self._url()).status_code, 404)
@@ -168,6 +168,123 @@ class TransportNormalizeTests(TestCase):
self.assertEqual(inbound.phone, "")
self.assertEqual(inbound.text, "Здравствуйте")
def test_max_voice_attachment_becomes_a_voice_message(self) -> None:
update = {
"update_type": "message_created",
"message": {
"sender": {"user_id": 42, "name": "Мария"},
"recipient": {"chat_id": 100},
"body": {
"mid": "m-3",
"text": "",
"attachments": [
{
"type": "audio",
"duration": 7,
"payload": {"url": "https://cdn.example.test/voice.ogg"},
}
],
},
},
}
inbound = max_transport._normalize(update)
self.assertIsNotNone(inbound)
self.assertEqual(inbound.voice_url, "https://cdn.example.test/voice.ogg")
self.assertEqual(inbound.voice_duration, 7)
self.assertFalse(inbound.voice_unavailable)
def test_max_voice_without_url_still_reaches_the_operator(self) -> None:
# Форма вложения у MAX описана неполно. Незнакомая — не повод молча
# терять реплику клиента: оператор увидит её заглушкой.
update = {
"update_type": "message_created",
"message": {
"sender": {"user_id": 42, "name": "Мария"},
"recipient": {"chat_id": 100},
"body": {
"mid": "m-4",
"text": "",
"attachments": [{"type": "audio", "payload": {"token": "abc"}}],
},
},
}
inbound = max_transport._normalize(update)
self.assertIsNotNone(inbound)
self.assertTrue(inbound.voice_unavailable)
self.assertEqual(inbound.voice_url, "")
def test_max_event_without_body_pulls_the_message_from_the_chat(self) -> None:
# Боевой случай: на голосовое MAX присылает message_created вообще без
# message. Содержимое достаём отдельным запросом, иначе реплика клиента
# не доедет до оператора.
integration = mock.Mock(secret="token", id=7, config={})
updates = {
"updates": [
{"timestamp": 1789442982299, "user_locale": "ru", "update_type": "message_created"}
],
"marker": 42,
}
chats = {"chats": [{"chat_id": 100, "last_event_time": 1789442982299}]}
messages = {
"messages": [
{
"sender": {"user_id": 42, "name": "Мария"},
"recipient": {"chat_id": 100},
"body": {
"mid": "m-9",
"text": "",
"attachments": [
{
"type": "audio",
"duration": 5,
"payload": {"url": "https://cdn.example.test/v.ogg"},
}
],
},
}
]
}
def fake_request(url, **kwargs):
if "/updates" in url:
return updates
if "/chats" in url:
return chats
return messages
with mock.patch(
"chatballs.conversations.transports.max.request_json", side_effect=fake_request
):
found, marker = max_transport.poll_updates(integration)
self.assertEqual(marker, "42")
self.assertEqual(len(found), 1)
self.assertEqual(found[0].external_id, "m-9")
self.assertEqual(found[0].voice_url, "https://cdn.example.test/v.ogg")
def test_max_does_not_touch_chats_when_the_event_has_a_body(self) -> None:
integration = mock.Mock(secret="token", id=7, config={})
updates = {
"updates": [
{
"update_type": "message_created",
"timestamp": 1,
"message": {
"sender": {"user_id": 42, "name": "Мария"},
"recipient": {"chat_id": 100},
"body": {"mid": "m-1", "text": "Здравствуйте"},
},
}
],
"marker": 7,
}
with mock.patch(
"chatballs.conversations.transports.max.request_json", return_value=updates
) as request:
found, _marker = max_transport.poll_updates(integration)
self.assertEqual(len(found), 1)
self.assertEqual(request.call_count, 1)
class ContactShareIngestTests(TestCase):
"""Шаринг контакта: телефон сохраняется в Contact, AI-ход не запускается,
@@ -154,6 +154,36 @@ def send_file(integration, *, chat_id: str, user_id: str, content: bytes, filena
)
# Фото контакта (chatballs.conversations.contact_avatars): MAX отдаёт адрес в
# профиле отправителя, Telegram — только по отдельному запросу к API.
def avatar_source(integration, inbound) -> str:
"""Чем определяется «то же самое фото» у этого провайдера.
Пока значение не изменилось, фото не перекачивается. У MAX это адрес из
апдейта, у Telegram — сам отправитель: апдейт про фото ничего не говорит,
и спрашивать API на каждое сообщение было бы расточительством.
"""
if integration.provider == IntegrationProvider.TELEGRAM:
return f"tg:{inbound.user_id}" if inbound.user_id else ""
return inbound.avatar_url
def download_avatar(integration, inbound) -> tuple[bytes, str] | None:
"""Фото отправителя: (байты, ключ источника). None — фото у провайдера нет."""
source = avatar_source(integration, inbound)
if not source:
return None
if integration.provider == IntegrationProvider.TELEGRAM:
content = _telegram.download_profile_photo(integration, inbound.user_id)
return (content, source) if content else None
if integration.provider == IntegrationProvider.MAX and inbound.avatar_url:
content, _content_type = _max.download_file(integration, inbound.avatar_url, "")
return (content, source) if content else None
return None
def download_voice(integration, inbound) -> tuple[bytes, str]:
if inbound.voice_content:
return inbound.voice_content, inbound.voice_mime or "audio/webm"
@@ -60,6 +60,10 @@ class InboundMessage:
voice_content: bytes = b""
voice_duration: int = 0
voice_mime: str = ""
# Голосовое в сообщении есть, но источника для скачивания провайдер не дал
# (незнакомая форма вложения). Сообщение всё равно доезжает до оператора
# заглушкой: молча терять реплику клиента нельзя.
voice_unavailable: bool = False
# Файлы и фото: каждый становится отдельным сообщением kind=file.
files: tuple[InboundFile, ...] = field(default_factory=tuple)
@@ -109,9 +109,10 @@ def _normalize(update: dict) -> InboundMessage | None:
chat_id = first(recipient, "chat_id", "chatId")
external_id = first(inner, "mid", "msgId", "seq") or first(update, "update_id", "updateId", "timestamp")
phone = _contact_phone(inner, msg)
voice_url, voice_duration = _voice_attachment(inner)
voice_url, voice_duration, voice_unavailable = _voice_attachment(inner)
files = _file_attachments(inner)
if (not text and not phone and not voice_url and not files) or user_id is None or external_id is None:
has_voice = bool(voice_url) or voice_unavailable
if (not text and not phone and not has_voice and not files) or user_id is None or external_id is None:
return None
return InboundMessage(
external_id=str(external_id),
@@ -125,6 +126,7 @@ def _normalize(update: dict) -> InboundMessage | None:
voice_url=voice_url,
voice_duration=voice_duration,
voice_mime="audio/ogg" if voice_url else "",
voice_unavailable=voice_unavailable,
files=files,
)
@@ -221,15 +223,91 @@ def send_file(integration, *, chat_id: str, user_id: str, content: bytes, filena
return False
def _voice_attachment(inner: dict) -> tuple[str, int]:
"""Голосовое/аудио-вложение MAX: payload.url для скачивания."""
def _voice_attachment(inner: dict) -> tuple[str, int, bool]:
"""Голосовое/аудио-вложение MAX: адрес для скачивания и длительность.
Третье значение — «вложение было, а адреса в нём нет». Форма вложения у MAX
описана неполно, и раньше такое сообщение уходило в никуда: текста в нём
нет, адреса нет — normalize возвращал None, и реплика клиента просто
пропадала. Теперь оператор видит её заглушкой, а в журнале остаётся сам
payload, по которому разбирают форму.
"""
for attachment in inner.get("attachments") or []:
if attachment.get("type") in ("audio", "voice"):
payload = attachment.get("payload") or {}
url = str(first(payload, "url", "download_url", default=""))
if url:
return url, int(first(attachment, "duration", default=0) or payload.get("duration") or 0)
return "", 0
if attachment.get("type") not in ("audio", "voice"):
continue
payload = attachment.get("payload") or {}
duration = int(first(attachment, "duration", default=0) or payload.get("duration") or 0)
url = str(first(payload, "url", "download_url", default=""))
if url:
return url, duration, False
logger.warning(
"MAX voice attachment without a download url: %s",
json.dumps(attachment, ensure_ascii=False),
)
return "", duration, True
return "", 0, False
# Насколько раньше события смотреть чаты, когда MAX прислал событие без тела.
BODYLESS_LOOKBACK_MS = 15_000
# Сколько чатов и сообщений забирать в этом случае: событие одно, чат почти
# всегда один, а перебирать весь список чатов на каждое такое событие нельзя.
BODYLESS_CHATS_LIMIT = 5
BODYLESS_MESSAGES_PER_CHAT = 3
def _is_bodyless(update: dict) -> bool:
"""Событие «пришло сообщение», в котором самого сообщения нет.
MAX присылает такое, например, на голосовые: update_type есть, timestamp
есть, а `message` отсутствует целиком. Разбирать в нём нечего, и раньше
реплика клиента на этом заканчивалась.
"""
if (update.get("update_type") or update.get("updateType")) != "message_created":
return False
return not (update.get("message") or update.get("payload"))
def _api(integration, path: str) -> dict:
return request_json(
f"{_base(integration)}{path}",
headers={"Authorization": integration.secret, "Content-Type": "application/json"},
proxy_url=_proxy(integration),
)
def _messages_since(integration, timestamp: int) -> list[InboundMessage]:
"""Свежие сообщения из чатов бота — ответ на событие без тела.
Забираются последние реплики тех чатов, где что-то происходило рядом с
событием, и прогоняются через тот же разбор. Повторы безвредны: входящие
отсеиваются по идентификатору сообщения (conversations.ingest).
"""
chats = (_api(integration, f"/chats?count={BODYLESS_CHATS_LIMIT * 4}") or {}).get("chats") or []
recent = [
chat
for chat in chats
if int(first(chat, "last_event_time", "lastEventTime", default=0) or 0)
>= timestamp - BODYLESS_LOOKBACK_MS
]
recent.sort(
key=lambda chat: int(first(chat, "last_event_time", "lastEventTime", default=0) or 0),
reverse=True,
)
found: list[InboundMessage] = []
for chat in recent[:BODYLESS_CHATS_LIMIT]:
chat_id = first(chat, "chat_id", "chatId")
if chat_id is None:
continue
payload = _api(
integration,
f"/messages?chat_id={chat_id}&count={BODYLESS_MESSAGES_PER_CHAT}",
)
for message in payload.get("messages") or []:
inbound = _normalize({"update_type": "message_created", "message": message})
if inbound is not None:
found.append(inbound)
return found
def poll_updates(integration) -> tuple[list[InboundMessage], str]:
@@ -246,6 +324,15 @@ def poll_updates(integration) -> tuple[list[InboundMessage], str]:
raise PollFailed(str(error)) from error
updates = data.get("updates") or []
messages = [m for m in (_normalize(u) for u in updates) if m is not None]
# Событие без тела: содержимое забираем отдельным запросом, иначе сообщение
# клиента (в частности, голосовое) до оператора не доедет вовсе.
bodyless = [u for u in updates if _is_bodyless(u)]
if bodyless:
timestamp = min(int(u.get("timestamp") or 0) for u in bodyless)
try:
messages.extend(_messages_since(integration, timestamp))
except (urllib.error.URLError, TimeoutError, OSError, http.client.HTTPException, json.JSONDecodeError) as error:
logger.warning("MAX chat history fetch failed for integration %s: %s", integration.id, error)
new_marker = data.get("marker")
return messages, ("" if new_marker is None else str(new_marker))
@@ -180,6 +180,33 @@ def send_call_invite(integration, *, chat_id: str, user_id: str, text: str, url:
return _send(integration, chat_id=chat_id, user_id=user_id, body={"text": text, "reply_markup": keyboard})
def download_profile_photo(integration, user_id: str) -> bytes | None:
"""Фото профиля отправителя: getUserProfilePhotos -> file_id -> getFile.
Telegram не кладёт фото в апдейт, поэтому его спрашивают отдельно — один
раз на контакт (chatballs.conversations.contact_avatars). None — фото у
человека нет или оно закрыто настройками приватности.
"""
token = integration.secret
if not token or not user_id:
return None
data = request_json(
f"{_base(integration)}/bot{token}/getUserProfilePhotos?user_id={user_id}&limit=1",
proxy_url=_proxy(integration),
)
photos = ((data.get("result") or {}).get("photos") or []) if data.get("ok") else []
if not photos or not photos[0]:
return None
# Размеры идут от меньшего к большему; аватар рисуется 40-64px, поэтому
# средний размер лучше самого большого — он в разы легче.
sizes = [size for size in photos[0] if size.get("file_id")]
if not sizes:
return None
chosen = sizes[min(1, len(sizes) - 1)]
content, _content_type = download_file(integration, str(chosen["file_id"]))
return content
def download_file(integration, file_id: str) -> tuple[bytes, str]:
"""Скачивание файла по file_id: getFile -> file_path -> /file/bot<token>/<path>."""
token = integration.secret
@@ -14,6 +14,7 @@ urlpatterns = [
path("stats/", reporting_views.ConversationStatsView.as_view(), name="conversation-stats"),
path("clients/", reporting_views.ClientsView.as_view(), name="conversation-clients"),
path("clients/<int:contact_id>/", reporting_views.ClientDetailView.as_view(), name="conversation-client-detail"),
path("clients/<int:contact_id>/avatar/", reporting_views.ContactAvatarView.as_view(), name="conversation-client-avatar"),
path("clients/<int:contact_id>/merge/", reporting_views.ClientMergeView.as_view(), name="conversation-client-merge"),
path("<int:conversation_id>/", views.ConversationDetailView.as_view(), name="conversation-detail"),
path("<int:conversation_id>/claim/", views.ConversationClaimView.as_view(), name="conversation-claim"),
@@ -33,6 +33,7 @@ from chatballs.conversations.services import (
assign_operator,
claim_conversation,
close_conversation,
delete_conversation,
mark_conversation_as_spam,
post_operator_message,
release_to_ai,
@@ -187,6 +188,28 @@ class ConversationDetailView(ConversationViewBase):
}
)
def delete(self, request: Request, conversation_id: int) -> Response:
"""Удалить диалог вместе с перепиской — насовсем.
Право есть только у владельца и администратора: оператор закрывает
диалог и помечает спам, но не стирает историю организации.
"""
try:
conversation = self._conversation(
request, conversation_id, "conversations.operate"
)
except Conversation.DoesNotExist:
return Response({"detail": t("conversations.not_found")}, status=404)
if not can_administer_access(request.tenant_context.membership):
return Response(
{"detail": t("conversations.delete_admin_only")}, status=403
)
# Запись журнала — до удаления: после него у диалога уже нет ни строки,
# ни идентификатора, который можно было бы записать.
self._audit(request, "deleted", conversation)
delete_conversation(context=request.tenant_context, conversation=conversation)
return Response(status=204)
class ConversationClaimView(ConversationViewBase):
required_capability = "conversations.operate"
+3 -1
View File
@@ -53,6 +53,7 @@ MESSAGES: dict[str, object] = {
"conversations.not_a_voice_message": "This is not a voice message",
"conversations.not_found": "Conversation not found",
"conversations.note_too_long": "The note is longer than 4000 characters",
"conversations.delete_admin_only": "Only the owner or an administrator can delete a conversation",
"conversations.restore_admin_only": "Only an administrator can restore a conversation",
"conversations.system.assigned_to": "Conversation assigned to {operator}",
"conversations.system.assignment_expired": "{operator} did not pick the conversation up — it is back in the queue",
@@ -197,6 +198,7 @@ MESSAGES: dict[str, object] = {
"audit.action_contacts_unmerged": "Contacts unmerged",
"audit.action_conversation_contact_updated": "Conversation contact changed",
"audit.action_conversations_archived": "Conversation moved to the archive",
"audit.action_conversations_deleted": "Conversation deleted",
"audit.action_conversations_assignee_changed": "Conversation assignee changed",
"audit.action_conversations_claimed": "Conversation taken",
"audit.action_conversations_closed": "Conversation closed",
@@ -498,7 +500,7 @@ MESSAGES: dict[str, object] = {
"notifications.type_dialog_new_message": "A new message in my conversation",
"notifications.type_dialog_assigned": "A conversation was assigned to me",
"notifications.type_dialog_waiting_long": "A conversation has been waiting a long time",
"notifications.type_ai_stopped": "AI stopped by an error or a limit",
"notifications.type_ai_stopped": "AI stopped by an error",
"integrations.check_no_connection": "No connection: {error}",
"integrations.check_api_key_missing": "The API key is not set",
"integrations.check_base_url_missing": "Base URL is not set",
+3 -1
View File
@@ -57,6 +57,7 @@ MESSAGES: dict[str, object] = {
"conversations.not_a_voice_message": "Это не голосовое сообщение",
"conversations.not_found": "Диалог не найден",
"conversations.note_too_long": "Заметка длиннее 4000 символов",
"conversations.delete_admin_only": "Удалить диалог может владелец или администратор",
"conversations.restore_admin_only": "Восстановить диалог может только администратор",
"conversations.system.ai_handed_over": "AI передал диалог оператору",
"conversations.system.assigned_to": "Диалог назначен на {operator}",
@@ -201,6 +202,7 @@ MESSAGES: dict[str, object] = {
"audit.action_contacts_unmerged": "Разъединение контактов",
"audit.action_conversation_contact_updated": "Изменён контакт диалога",
"audit.action_conversations_archived": "Диалог отправлен в архив",
"audit.action_conversations_deleted": "Диалог удалён",
"audit.action_conversations_assignee_changed": "Изменён ответственный за диалог",
"audit.action_conversations_claimed": "Диалог взят в работу",
"audit.action_conversations_closed": "Диалог закрыт",
@@ -502,7 +504,7 @@ MESSAGES: dict[str, object] = {
"notifications.type_dialog_new_message": "Новое сообщение в моём диалоге",
"notifications.type_dialog_assigned": "Диалог назначили на меня",
"notifications.type_dialog_waiting_long": "Диалог долго ждёт человека",
"notifications.type_ai_stopped": "AI остановлен ошибкой или лимитом",
"notifications.type_ai_stopped": "AI остановлен ошибкой",
"integrations.check_no_connection": "Нет связи: {error}",
"integrations.check_api_key_missing": "Не указан API-ключ",
"integrations.check_base_url_missing": "Не указан Base URL",
@@ -148,6 +148,7 @@ AUDIT_ACTION_LABELS: dict[str, str] = {
"conversations.group_changed": "audit.action_conversations_group_changed",
"conversations.priority_changed": "audit.action_conversations_priority_changed",
"conversations.closed": "audit.action_conversations_closed",
"conversations.deleted": "audit.action_conversations_deleted",
"conversations.archived": "audit.action_conversations_archived",
"conversations.unarchived": "audit.action_conversations_unarchived",
"conversations.marked_spam": "audit.action_conversations_marked_spam",
@@ -258,11 +258,39 @@ def email_from_address() -> str:
return str(settings.DEFAULT_FROM_EMAIL)
def turn_config() -> tuple[list[str], int]:
"""Адреса TURN и время жизни credentials из настроек установки.
# Порт relay в коробке: на нём coturn слушает и TURN, и STUN (compose.yaml).
TURN_PORT = 3478
Пустой список означает «relay не настроен»: звонки идут напрямую и через
STUN. Переменная окружения, если задана, побеждает.
def default_turn_urls(host: str = "") -> list[str]:
"""Адреса relay, которые работают в коробке без единой настройки.
Relay стоит на том же сервере и на том же адресе, что и сама установка, —
адрес известен с мастера первого запуска, и заставлять человека вписывать
его руками незачем. UDP идёт первым, TCP — запасным для сетей, где UDP
режут.
"""
host = host or public_host()
if not host:
return []
return [
f"turn:{host}:{TURN_PORT}?transport=udp",
f"turn:{host}:{TURN_PORT}?transport=tcp",
]
def default_stun_urls(host: str = "") -> list[str]:
"""STUN отдаёт тот же coturn на том же порту."""
host = host or public_host()
return [f"stun:{host}:{TURN_PORT}"] if host else []
def turn_config() -> tuple[list[str], int]:
"""Адреса TURN и время жизни credentials.
Порядок: переменная окружения (если её всё-таки задали), затем то, что
владелец вписал в «Настройки», затем адреса коробки по адресу установки.
Пустой список остаётся только там, где адрес установки ещё не известен.
"""
from django.conf import settings
@@ -276,6 +304,6 @@ def turn_config() -> tuple[list[str], int]:
# настройках процесса.
return [], settings.CHATBALLS_CALL_TURN_TTL_SECONDS
if row is None or not row.turn_urls.strip():
return [], settings.CHATBALLS_CALL_TURN_TTL_SECONDS
return default_turn_urls(), settings.CHATBALLS_CALL_TURN_TTL_SECONDS
urls = [line.strip() for line in row.turn_urls.splitlines() if line.strip()]
return urls, row.turn_ttl_seconds or settings.CHATBALLS_CALL_TURN_TTL_SECONDS
@@ -17,6 +17,7 @@ from chatballs.i18n.languages import DEFAULT_LANGUAGE, LANGUAGES, normalize_lang
from chatballs.identity.instance_access import InstanceSettingsPermission, IsInstanceAdmin
from chatballs.identity.instance_settings import (
InstanceSettings,
default_turn_urls,
email_connection,
email_from_address,
invalidate_cache,
@@ -50,7 +51,13 @@ def instance_payload(row: InstanceSettings) -> dict:
"turn": {
# Секрет общий с coturn и лежит в томе секретов: наружу не отдаём
# и в настройках не показываем — вводить его человеку не нужно.
"urls": [line for line in row.turn_urls.splitlines() if line.strip()],
#
# Адреса показываются действующие: пока владелец их не менял, это
# relay коробки на адресе установки. Пустое поле означало бы, что
# звонки через relay не работают, пока человек что-то впишет, —
# а они работают сразу после установки.
"urls": [line for line in row.turn_urls.splitlines() if line.strip()]
or default_turn_urls(),
"ttlSeconds": row.turn_ttl_seconds,
"secretReady": bool(settings.CHATBALLS_CALL_TURN_SECRET),
},
+1 -1
View File
@@ -53,7 +53,7 @@ def last_seen(organization_id: int, user_ids: Iterable[int]) -> dict[int, dateti
return {
keys[key]: datetime.fromtimestamp(value, tz=timezone.get_current_timezone())
for key, value in found.items()
if isinstance(value, (int, float))
if isinstance(value, int | float)
}
+28 -1
View File
@@ -15,6 +15,7 @@ from chatballs.conversations.models import (
ControlMode,
Conversation,
LifecycleState,
Message,
MessageKind,
)
from chatballs.conversations.transports.base import InboundMessage
@@ -311,6 +312,21 @@ def _call_payload(session: WebSession) -> dict | None:
return public_invite_payload(call, call.invite.expires_at)
def _thread_is_gone(session: WebSession, since: int) -> bool:
"""Переписка, которую показывает виджет, перестала существовать.
Диалог могли удалить в рабочем месте — вместе со всеми сообщениями. Тогда
сообщения, до которого досчитал виджет, больше нет, и показывать клиенту
переписку, которой не существует, нельзя. Закрытый диалог под это правило
не подпадает: его сообщения на месте.
"""
return since > 0 and not Message.objects.filter(
id=since,
conversation__contact_id=session.identity.contact_id,
conversation__channel_id=session.connection.channel_id,
).exists()
def messages_payload(session: WebSession, since: int) -> dict:
conversation = (
Conversation.objects.filter(
@@ -319,10 +335,21 @@ def messages_payload(session: WebSession, since: int) -> dict:
.order_by("-last_activity_at")
.first()
)
reset = _thread_is_gone(session, since)
if reset:
# Лента виджета начинается заново: то, что осталось, отдаётся целиком.
since = 0
if conversation is None:
return {"state": "ai", "lifecycle": LifecycleState.OPEN, "messages": [], "call": None}
return {
"state": "ai",
"lifecycle": LifecycleState.OPEN,
"messages": [],
"call": None,
"reset": reset,
}
items = conversation.messages.filter(id__gt=since).order_by("created_at")
return {
"reset": reset,
"state": _STATE.get(conversation.control_mode, "ai"),
"lifecycle": conversation.lifecycle,
"messages": [
+35
View File
@@ -187,6 +187,41 @@ class PublicWebChatWidgetTests(TestCase):
conversation.refresh_from_db()
self.assertEqual(conversation.control_mode, ControlMode.PAUSED)
def test_deleted_conversation_resets_widget_thread(self) -> None:
# Диалог удалили в рабочем месте: виджет должен начать с чистого листа,
# а не показывать клиенту переписку, которой больше нет.
from chatballs.conversations.models import Conversation
from chatballs.conversations.services import delete_conversation
from chatballs.tenancy.context import TenantContext
widget = create_web_widget(self.channel, name="Виджет")
token = self._session(widget.public_key).json()["token"]
self.client.post(
"/api/v1/webchat/messages/",
data=json.dumps({"text": "Здравствуйте"}),
content_type="application/json",
headers={"Authorization": f"Bearer {token}"},
)
payload = self.client.get(
"/api/v1/webchat/messages/?since=0",
headers={"Authorization": f"Bearer {token}"},
).json()
self.assertFalse(payload["reset"])
last_id = max(message["id"] for message in payload["messages"])
conversation = Conversation.objects.get(channel=self.channel)
delete_conversation(
context=TenantContext.for_resource(self.organization),
conversation=conversation,
)
after = self.client.get(
f"/api/v1/webchat/messages/?since={last_id}",
headers={"Authorization": f"Bearer {token}"},
).json()
self.assertTrue(after["reset"])
self.assertEqual(after["messages"], [])
def test_widget_origin_policy_is_scoped_per_widget(self) -> None:
allowed = create_web_widget(
self.channel,
@@ -1,39 +1,32 @@
import { Modal } from "antd";
import { useEffect, useMemo, useState } from "react";
import { Icon } from "../../shared/icons";
import { LoadingState } from "../../shared/ui";
import { Button, SearchInput } from "../../shared/ui-controls";
import { shortDate } from "../../shared/utils";
import { fetchKnowledgeList } from "../ai/knowledge/api";
import { listPortalArticles, listSupportPortals } from "../support-portals/api";
import { AgentKnowledgeGroup } from "./AgentKnowledgeGroup";
import { filterGroups, loadChoiceGroups, pickedIds, pickedInGroup, type ChoiceGroup } from "./agentKnowledgeGroups";
import type { AgentCard } from "./model";
import { t } from "../../i18n";
// «Выбрать» в блоке «Знания» карточки агента (кадры G3/G5): один список из
// материалов библиотеки и опубликованных статей порталов. Агент отвечает
// только по явно выбранным знаниям (ADR-CHATBALLS-0041 §8).
type Choice = {
key: string;
kind: "knowledge" | "article";
id: number;
title: string;
icon: "doc" | "globe";
meta: string;
disabled: boolean;
};
// «Выбрать» в блоке «Знания» карточки агента (кадры G3/G5): материалы
// библиотеки и опубликованные статьи порталов, разложенные по категориям и
// порталам. Агент отвечает только по явно выбранным знаниям
// (ADR-CHATBALLS-0041 §8).
//
// Развёрнуты сразу только те группы, где уже что-то выбрано: так видно, что
// агент читает сейчас, а остальное дерево не занимает окно целиком.
export function AgentKnowledgeDialog({ card, onClose, onSave }: {
card: AgentCard;
onClose: () => void;
onSave: (selection: { knowledgeIds: number[]; articleIds: number[] }) => Promise<void>;
}) {
const [choices, setChoices] = useState<Choice[] | null>(null);
const [groups, setGroups] = useState<ChoiceGroup[] | null>(null);
const [failed, setFailed] = useState(false);
const [search, setSearch] = useState("");
const [saving, setSaving] = useState(false);
const [error, setError] = useState("");
const [closed, setClosed] = useState<Set<string>>(() => new Set());
const [picked, setPicked] = useState<Set<string>>(
() => new Set([
...card.knowledge.map((item) => `k${item.id}`),
@@ -43,67 +36,38 @@ export function AgentKnowledgeDialog({ card, onClose, onSave }: {
useEffect(() => {
let cancelled = false;
async function load() {
try {
const library = await fetchKnowledgeList();
const portals = await listSupportPortals();
const articleLists = await Promise.all(portals.items.map((portal) => listPortalArticles(portal.id)));
loadChoiceGroups()
.then((loaded) => {
if (cancelled) return;
const articles: Choice[] = portals.items.flatMap((portal, index) => articleLists[index].items
.filter((article) => article.status === "PUBLISHED")
.map((article) => ({
key: `a${article.id}`,
kind: "article" as const,
id: article.id,
title: article.publishedRevision?.title ?? article.slug,
icon: "globe" as const,
meta: t("ai.portal_named", { name: portal.name }),
disabled: false,
})));
setChoices([
...library.items.map((item): Choice => ({
key: `k${item.id}`,
kind: "knowledge",
id: item.id,
title: item.title,
icon: "doc",
meta: item.isEnabled ? t("ai.updated_on", { date: shortDate(item.updatedAt) }) : t("common.off"),
disabled: false,
})),
...articles,
]);
} catch {
setGroups(loaded);
setClosed(new Set(loaded.filter((group) => pickedInGroup(group, picked) === 0).map((group) => group.key)));
})
.catch(() => {
if (!cancelled) setFailed(true);
}
}
void load();
});
return () => { cancelled = true; };
// Набор выбранного на момент загрузки берётся из карточки и до ответа
// сервера не меняется — перезапускать загрузку на каждый клик незачем.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const visible = useMemo(() => {
const needle = search.trim().toLowerCase();
if (!needle || choices === null) return choices ?? [];
return choices.filter((choice) => choice.title.toLowerCase().includes(needle));
}, [choices, search]);
const visible = useMemo(() => filterGroups(groups ?? [], search), [groups, search]);
const searching = Boolean(search.trim());
function toggle(key: string) {
function toggle(keys: string[], pick: boolean) {
setPicked((current) => {
const next = new Set(current);
if (next.has(key)) next.delete(key);
else next.add(key);
keys.forEach((key) => (pick ? next.add(key) : next.delete(key)));
return next;
});
}
async function submit() {
if (choices === null) return;
if (groups === null) return;
setSaving(true);
setError("");
try {
await onSave({
knowledgeIds: choices.filter((choice) => choice.kind === "knowledge" && picked.has(choice.key)).map((choice) => choice.id),
articleIds: choices.filter((choice) => choice.kind === "article" && picked.has(choice.key)).map((choice) => choice.id),
});
await onSave(pickedIds(groups, picked));
onClose();
} catch (caught) {
setError(caught instanceof Error ? caught.message : t("ai.could_not_save_selection"));
@@ -112,28 +76,39 @@ export function AgentKnowledgeDialog({ card, onClose, onSave }: {
}
return (
<Modal className="agent-knowledge-modal" open width={560} title={t("ai.select_knowledge")} onCancel={onClose} footer={null} destroyOnClose>
<Modal className="agent-knowledge-modal" open width={620} title={t("ai.select_knowledge")} onCancel={onClose} footer={null} destroyOnClose>
<p className="agent-create-lead">{t("ai.agent_answers_only_from_selected")}</p>
<SearchInput className="agent-knowledge-search" placeholder={t("ai.search_by_title")} value={search} onChange={setSearch} />
{choices === null && !failed && <LoadingState />}
{groups === null && !failed && <LoadingState />}
{failed && <div className="agent-form-error">{t("ai.could_not_load_material_list")}</div>}
{choices !== null && (
<div className="agent-knowledge-choices">
{visible.length === 0 && <p className="agent-knowledge-nothing">{t("common.nothing_found")}</p>}
{visible.map((choice) => (
<label className={`agent-knowledge-choice ${picked.has(choice.key) ? "is-picked" : ""}`} key={choice.key}>
<input type="checkbox" checked={picked.has(choice.key)} onChange={() => toggle(choice.key)} />
<Icon name={choice.icon} size={15} strokeWidth={1.9} />
<strong>{choice.title}</strong>
<small>{choice.meta}</small>
</label>
))}
</div>
{groups !== null && (
<>
<div className="agent-knowledge-summary">{t("common.selected_count", { count: picked.size })}</div>
<div className="agent-knowledge-choices">
{visible.length === 0 && <p className="agent-knowledge-nothing">{t("common.nothing_found")}</p>}
{visible.map((group) => (
<AgentKnowledgeGroup
group={group}
key={group.key}
open={searching || !closed.has(group.key)}
picked={picked}
onToggleAll={toggle}
onToggleChoice={(key) => toggle([key], !picked.has(key))}
onToggleOpen={() => setClosed((current) => {
const next = new Set(current);
if (next.has(group.key)) next.delete(group.key);
else next.add(group.key);
return next;
})}
/>
))}
</div>
</>
)}
{error && <div className="agent-form-error">{error}</div>}
<div className="agent-create-actions">
<Button variant="secondary" onClick={onClose}>{t("common.cancel")}</Button>
<Button variant="primary" disabled={choices === null || saving} onClick={() => void submit()}>
<Button variant="primary" disabled={groups === null || saving} onClick={() => void submit()}>
{saving ? t("ai.saving") : t("common.save")}
</Button>
</div>
@@ -0,0 +1,49 @@
import { Icon } from "../../shared/icons";
import { pickedInGroup, type ChoiceGroup } from "./agentKnowledgeGroups";
import { t } from "../../i18n";
// Одна категория (или портал) в диалоге «Знания» карточки агента: заголовок со
// счётчиком выбранного, «выбрать все» и список материалов.
export function AgentKnowledgeGroup({ group, picked, open, onToggleOpen, onToggleChoice, onToggleAll }: {
group: ChoiceGroup;
picked: ReadonlySet<string>;
open: boolean;
onToggleOpen: () => void;
onToggleChoice: (key: string) => void;
onToggleAll: (keys: string[], pick: boolean) => void;
}) {
const inGroup = pickedInGroup(group, picked);
const allPicked = inGroup === group.choices.length;
return (
<div className="agent-knowledge-group">
<div className="agent-knowledge-group-head">
<button
aria-expanded={open}
className={`agent-knowledge-group-toggle${open ? " is-open" : ""}`}
type="button"
onClick={onToggleOpen}
>
<Icon name="chevronRight" size={13} strokeWidth={2.2} />
<Icon name={group.icon} size={14} strokeWidth={1.8} />
<strong>{group.title}</strong>
<small>{inGroup > 0 ? `${inGroup} / ${group.choices.length}` : group.choices.length}</small>
</button>
<button
className="link is-muted"
type="button"
onClick={() => onToggleAll(group.choices.map((choice) => choice.key), !allPicked)}
>
{allPicked ? t("ai.clear_selection") : t("ai.select_all")}
</button>
</div>
{open && group.choices.map((choice) => (
<label className={`agent-knowledge-choice ${picked.has(choice.key) ? "is-picked" : ""}`} key={choice.key}>
<input type="checkbox" checked={picked.has(choice.key)} onChange={() => onToggleChoice(choice.key)} />
<strong>{choice.title}</strong>
<small>{choice.meta}</small>
</label>
))}
</div>
);
}
@@ -0,0 +1,116 @@
import { allPages } from "../../shared/allPages";
import { shortDate } from "../../shared/utils";
import { fetchAllKnowledge, fetchKnowledgeCategories } from "../ai/knowledge/api";
import { categoryRows } from "../ai/knowledge/knowledgeLibraryModel";
import { knowledgeCategoryPath } from "../ai/knowledge/knowledgeTree";
import { listPortalArticles, listSupportPortals } from "../support-portals/api";
import { t } from "../../i18n";
// Что можно выдать агенту: материалы библиотеки и опубликованные статьи
// порталов. Набор разложен по категориям библиотеки и по порталам — сплошным
// списком в организации с сотней материалов ничего не найти.
export type KnowledgeChoice = {
key: string;
kind: "knowledge" | "article";
id: number;
title: string;
meta: string;
};
export type ChoiceGroup = {
key: string;
title: string;
icon: "folder" | "globe";
choices: KnowledgeChoice[];
};
/** Все материалы и статьи, разложенные по категориям и порталам. */
export async function loadChoiceGroups(): Promise<ChoiceGroup[]> {
const [categories, items] = await Promise.all([fetchKnowledgeCategories(), fetchAllKnowledge()]);
// Порталы и статьи тоже приходят страницами: диалогу нужен весь набор,
// иначе статья со второй страницы портала агенту недоступна.
const portals = await allPages((page) => listSupportPortals(undefined, page));
const articleLists = await Promise.all(
portals.map((portal) => allPages((page) => listPortalArticles(portal.id, undefined, page))),
);
const byCategory = new Map<number, KnowledgeChoice[]>();
items.forEach((item) => {
const choices = byCategory.get(item.category.id) ?? [];
choices.push({
key: `k${item.id}`,
kind: "knowledge",
id: item.id,
title: item.title,
meta: item.isEnabled ? t("ai.updated_on", { date: shortDate(item.updatedAt) }) : t("common.off"),
});
byCategory.set(item.category.id, choices);
});
// Порядок категорий — тот же, что в дереве библиотеки; название группы —
// полный путь, иначе две «Общие» в разных ветках не различить.
const groups: ChoiceGroup[] = categoryRows(categories.items)
.filter(({ category }) => (byCategory.get(category.id) ?? []).length > 0)
.map(({ category }) => ({
key: `c${category.id}`,
title: knowledgeCategoryPath(categories.items, category.id) || category.name,
icon: "folder" as const,
choices: byCategory.get(category.id) ?? [],
}));
// Категория материала могла не приехать в справочник (её удалили из-под
// открытой карточки) — такие материалы всё равно должны быть видны.
const shown = new Set(groups.flatMap((group) => group.choices.map((choice) => choice.key)));
const orphans = items
.filter((item) => !shown.has(`k${item.id}`))
.map((item): KnowledgeChoice => ({
key: `k${item.id}`,
kind: "knowledge",
id: item.id,
title: item.title,
meta: item.category.name,
}));
if (orphans.length > 0) {
groups.push({ key: "c0", title: t("ai.other_knowledge"), icon: "folder", choices: orphans });
}
portals.forEach((portal, index) => {
const choices = articleLists[index]
.filter((article) => article.status === "PUBLISHED")
.map((article): KnowledgeChoice => ({
key: `a${article.id}`,
kind: "article",
id: article.id,
title: article.publishedRevision?.title ?? article.slug,
meta: t("ai.portal_named", { name: portal.name }),
}));
if (choices.length > 0) {
groups.push({ key: `p${portal.id}`, title: portal.name, icon: "globe", choices });
}
});
return groups;
}
/** Группы с материалами, подходящими под запрос; пустые группы уходят. */
export function filterGroups(groups: ChoiceGroup[], search: string): ChoiceGroup[] {
const needle = search.trim().toLowerCase();
if (!needle) return groups;
return groups
.map((group) => ({ ...group, choices: group.choices.filter((choice) => choice.title.toLowerCase().includes(needle)) }))
.filter((group) => group.choices.length > 0);
}
export function pickedInGroup(group: ChoiceGroup, picked: ReadonlySet<string>): number {
return group.choices.filter((choice) => picked.has(choice.key)).length;
}
/** Идентификаторы выбранного по видам — то, что уходит на сервер. */
export function pickedIds(groups: ChoiceGroup[], picked: ReadonlySet<string>) {
const chosen = groups.flatMap((group) => group.choices).filter((choice) => picked.has(choice.key));
return {
knowledgeIds: chosen.filter((choice) => choice.kind === "knowledge").map((choice) => choice.id),
articleIds: chosen.filter((choice) => choice.kind === "article").map((choice) => choice.id),
};
}
@@ -391,13 +391,86 @@
margin-bottom: 10px;
}
.agent-knowledge-summary {
margin: 0 0 6px;
color: var(--n-4);
font-size: 12px;
font-weight: 600;
}
.agent-knowledge-choices {
display: flex;
max-height: 320px;
max-height: 380px;
flex-direction: column;
overflow-y: auto;
}
/* Группа: категория библиотеки или портал. */
.agent-knowledge-group + .agent-knowledge-group {
margin-top: 4px;
}
.agent-knowledge-group-head {
display: flex;
align-items: center;
gap: 8px;
height: 34px;
padding-right: 2px;
border-top: 1px solid var(--n-9);
}
.agent-knowledge-group-toggle {
display: flex;
min-width: 0;
flex: 1;
align-items: center;
gap: 8px;
height: 30px;
padding: 0 6px 0 2px;
border: 0;
border-radius: 7px;
background: transparent;
color: var(--n-4);
font-family: inherit;
text-align: left;
cursor: pointer;
}
.agent-knowledge-group-toggle:hover {
background: var(--n-9);
}
.agent-knowledge-group-toggle > svg:first-child {
flex: none;
transition: transform 140ms ease;
}
.agent-knowledge-group-toggle.is-open > svg:first-child {
transform: rotate(90deg);
}
.agent-knowledge-group-toggle strong {
min-width: 0;
flex: 1;
overflow: hidden;
color: var(--n-1);
font-size: 12.5px;
font-weight: 600;
white-space: nowrap;
text-overflow: ellipsis;
}
.agent-knowledge-group-toggle small {
flex: none;
color: var(--n-5);
font-size: 11.5px;
font-weight: 600;
}
.agent-knowledge-group .agent-knowledge-choice {
padding-left: 22px;
}
.agent-knowledge-choice {
display: flex;
align-items: center;
@@ -16,79 +16,8 @@ import {
} from "./model";
import { t, tn } from "../../../i18n";
import { readableSize } from "../../../shared/utils";
// Импорт YAML (дизайн-базлайн v2, кадр KB8): файл разбирается до применения —
// видно, что создастся, что обновится и где ошибка пути. Импорт не создаёт
// категории неявно, поэтому несуществующий categoryPath отклоняется отдельно.
type ImportAction = "create" | "update" | "error";
type ImportRow = {
document: ParsedKnowledgeYaml["documents"][number];
action: ImportAction;
path: string;
note: string;
};
const ACTION_LABEL: Record<ImportAction, string> = {
create: t("ai.create"),
update: t("ai.update"),
error: t("ai.error"),
};
/** Разрешает путь категории в дерево организации: путь ищется по уровням. */
function resolvePath(categories: KnowledgeCategory[], path: string[]): { ok: boolean; missing: string } {
let parentId: number | null = null;
for (const name of path) {
const found: KnowledgeCategory | undefined = categories.find(
(category) => category.parentId === parentId && category.name === name,
);
if (!found) return { ok: false, missing: name };
parentId = found.id;
}
return { ok: true, missing: "" };
}
function planRows(
parsed: ParsedKnowledgeYaml,
categories: KnowledgeCategory[],
items: KnowledgeItem[],
): ImportRow[] {
const byTitle = new Map(items.map((item) => [item.title, item]));
return parsed.documents.map((document) => {
const existing = byTitle.get(document.title) ?? null;
if (document.categoryPath) {
const { ok, missing } = resolvePath(categories, document.categoryPath);
if (!ok) {
return {
document,
action: "error",
path: document.categoryPath.join(" / "),
note: t("ai.missing_category_path", { name: missing }),
};
}
}
const path = document.categoryPath ? document.categoryPath.join(" / ") : t("ai.not_given");
if (!existing) {
return {
document,
action: "create",
path,
note: document.categoryPath ? "" : t("ai.will_land_no_category"),
};
}
const samePlace = document.categoryPath
&& document.categoryPath[document.categoryPath.length - 1] === existing.category.name;
return {
document,
action: "update",
path,
note: samePlace
? t("ai.category_file_matches_current_one")
: t("ai.title_matched_text_will_replaced"),
};
});
}
import { planRows } from "./knowledgeImportPlan";
import { KnowledgeImportPreview } from "./KnowledgeImportPreview";
export function KnowledgeImportPage({
canManage,
@@ -218,44 +147,11 @@ export function KnowledgeImportPage({
</div>
)}
{parsed && (
<div className="knowledge-import-plan">
<div className="knowledge-import-plan-head">
<strong>{t("ai.what_will_happen")}</strong>
<span>
{counts.create > 0 && <small className="is-create">{t("ai.count_create", { count: counts.create })}</small>}
{counts.update > 0 && <small className="is-update">{t("ai.count_update", { count: counts.update })}</small>}
{counts.error > 0 && <small className="is-error">{t("ai.count_error", { count: counts.error })}</small>}
</span>
</div>
<table className="knowledge-import-table">
<thead>
<tr>
<th>{t("ai.document")}</th>
<th>CATEGORYPATH</th>
<th>{t("ai.action")}</th>
<th>{t("ai.note")}</th>
</tr>
</thead>
<tbody>
{rows.map((row, index) => (
<tr className={row.action === "error" ? "is-error" : ""} key={`${row.document.title}-${index}`}>
<td><code>{row.document.title}</code></td>
<td className="knowledge-import-path">{row.path}</td>
<td><span className={`knowledge-import-action is-${row.action}`}>{ACTION_LABEL[row.action]}</span></td>
<td className="knowledge-import-note">{row.note}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{parsed && <KnowledgeImportPreview rows={rows} counts={counts} />}
{parsed && (
<div className="knowledge-import-actions">
<span>{t("ai.import_never_creates_categories_implicitly")}<code>categoryPath</code>{" "}
{t("ai.import_path_tail")}
</span>
<span>{t("ai.import_category_policy")}</span>
<Button variant="secondary" disabled={busy} onClick={() => setRoute("knowledge")}>{t("common.cancel")}</Button>
<Button variant="primary" disabled={busy || !canManage || importable.length === 0} onClick={() => void submit()}>
{t("ai.import_documents", { count: tn("plural.documents", importable.length) })}
@@ -0,0 +1,40 @@
import { t } from "../../../i18n";
import { ACTION_LABEL, type ImportAction, type ImportRow } from "./knowledgeImportPlan";
export function KnowledgeImportPreview({ rows, counts }: {
rows: ImportRow[];
counts: Record<ImportAction, number>;
}) {
return (
<div className="knowledge-import-plan">
<div className="knowledge-import-plan-head">
<strong>{t("ai.what_will_happen")}</strong>
<span>
{counts.create > 0 && <small className="is-create">{t("ai.count_create", { count: counts.create })}</small>}
{counts.update > 0 && <small className="is-update">{t("ai.count_update", { count: counts.update })}</small>}
{counts.error > 0 && <small className="is-error">{t("ai.count_error", { count: counts.error })}</small>}
</span>
</div>
<table className="knowledge-import-table">
<thead>
<tr>
<th>{t("ai.document")}</th>
<th>CATEGORYPATH</th>
<th>{t("ai.action")}</th>
<th>{t("ai.note")}</th>
</tr>
</thead>
<tbody>
{rows.map((row, index) => (
<tr className={row.action === "error" ? "is-error" : ""} key={`${row.document.title}-${index}`}>
<td><code>{row.document.title}</code></td>
<td className="knowledge-import-path">{row.path}</td>
<td><span className={`knowledge-import-action is-${row.action}`}>{ACTION_LABEL[row.action]}</span></td>
<td className="knowledge-import-note">{row.note}</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
@@ -1,5 +1,5 @@
import { Dropdown } from "antd";
import { useMemo, useState } from "react";
import { useState } from "react";
import { hasCapability } from "../../../auth/access";
import { Pagination } from "../../../shared/Pagination";
@@ -14,11 +14,10 @@ import { KnowledgeBulkBar } from "./KnowledgeBulkBar";
import { KnowledgeCategoryDialog } from "./KnowledgeCategoryDialog";
import {
answerStateLabel,
categoryBranch,
categoryRows,
knowledgeUpdatedAt,
libraryTotals,
} from "./knowledgeLibraryModel";
import { KnowledgeSections } from "./KnowledgeSections";
import { knowledgeCategoryPath } from "./knowledgeTree";
import {
bulkMoveKnowledge,
@@ -64,7 +63,6 @@ export function KnowledgeLibraryPage({
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const rows = useMemo(() => categoryRows(library.categories), [library.categories]);
const selectedCategory = library.filters.category;
const selected = [...library.selectedIds];
@@ -166,42 +164,13 @@ export function KnowledgeLibraryPage({
{error && <div className="knowledge-form-error">{error}</div>}
<div className="knowledge-library">
<aside className="knowledge-sections">
<header>
<span>{t("ai.categories")}</span>
{canManage && <button type="button" onClick={() => setRoute("knowledgeCategories")}>{t("ai.manage")}</button>}
</header>
<nav>
<button
className={`knowledge-section-row${selectedCategory === undefined ? " is-active" : ""}`}
style={{ paddingLeft: 9 }}
type="button"
onClick={() => library.updateFilter("category", undefined)}
>
<Icon name="folder" size={15} strokeWidth={1.8} />
<span>{t("ai.all_knowledge")}</span>
<small>{library.categories.filter((item) => item.parentId === null).reduce((total, item) => total + (item.knowledgeCount ?? 0), 0)}</small>
</button>
{rows.map(({ category, depth, count }) => (
<button
className={`knowledge-section-row${selectedCategory === category.id ? " is-active" : ""}`}
key={category.id}
style={{ paddingLeft: 9 + depth * 16 }}
type="button"
onClick={() => library.updateFilter("category", category.id)}
>
<span>{category.name}</span>
<small>{count}</small>
</button>
))}
</nav>
{canManage && (
<div className="knowledge-sections-foot">
<button type="button" onClick={() => setRoute("knowledgeCategories")}>
<Icon name="plus" size={14} strokeWidth={2} />{t("ai.add_category")}</button>
</div>
)}
</aside>
<KnowledgeSections
canManage={canManage}
categories={library.categories}
selected={selectedCategory}
onManage={() => setRoute("knowledgeCategories")}
onSelect={(categoryId) => library.updateFilter("category", categoryId)}
/>
<div className="knowledge-library-main">
<div className="knowledge-library-inner">
@@ -376,7 +345,9 @@ export function KnowledgeLibraryPage({
note={t("ai.library_note", { count: tn("plural.knowledge", library.total), shown: library.items.length })}
page={library.page}
pageCount={library.pageCount}
pageSize={library.pageSize}
onPage={library.setPage}
onPageSize={library.setPageSize}
/>
</>
)}
@@ -0,0 +1,112 @@
import { useMemo, useState } from "react";
import { Icon } from "../../../shared/icons";
import { t } from "../../../i18n";
import { categoryRows, visibleCategoryRows } from "./knowledgeLibraryModel";
import type { KnowledgeCategory } from "./model";
// Левая панель библиотеки знаний (кадр KB1): дерево категорий.
//
// Ветку с вложенными категориями сворачивают шевроном — у организации с
// трёхуровневым деревом список иначе занимает панель целиком. Свёрнутое
// помнит браузер: человек возвращается в раздел к тому же виду, что оставил.
const STORAGE_KEY = "chatballs.ui.knowledge.collapsed";
function readCollapsed(): Set<number> {
try {
const raw = window.localStorage.getItem(STORAGE_KEY);
const stored = raw ? (JSON.parse(raw) as unknown) : null;
return new Set(Array.isArray(stored) ? stored.filter((item): item is number => typeof item === "number") : []);
} catch {
return new Set();
}
}
function rememberCollapsed(collapsed: Set<number>): void {
try {
window.localStorage.setItem(STORAGE_KEY, JSON.stringify([...collapsed]));
} catch {
/* без хранилища дерево просто не помнит свёрнутое между заходами */
}
}
export function KnowledgeSections({ categories, selected, canManage, onSelect, onManage }: {
categories: KnowledgeCategory[];
selected: number | undefined;
canManage: boolean;
onSelect: (categoryId: number | undefined) => void;
onManage: () => void;
}) {
const [collapsed, setCollapsed] = useState<Set<number>>(readCollapsed);
const rows = useMemo(() => categoryRows(categories), [categories]);
const visible = useMemo(() => visibleCategoryRows(rows, collapsed), [rows, collapsed]);
const total = categories
.filter((category) => category.parentId === null)
.reduce((sum, category) => sum + (category.knowledgeCount ?? 0), 0);
function toggle(categoryId: number) {
setCollapsed((current) => {
const next = new Set(current);
if (next.has(categoryId)) next.delete(categoryId);
else next.add(categoryId);
rememberCollapsed(next);
return next;
});
}
return (
<aside className="knowledge-sections">
<header>
<span>{t("ai.categories")}</span>
{canManage && <button type="button" onClick={onManage}>{t("ai.manage")}</button>}
</header>
<nav>
<div className={`knowledge-section-row${selected === undefined ? " is-active" : ""}`}>
<i className="knowledge-section-twist" />
<button className="knowledge-section-pick" type="button" onClick={() => onSelect(undefined)}>
<Icon name="folder" size={15} strokeWidth={1.8} />
<span>{t("ai.all_knowledge")}</span>
<small>{total}</small>
</button>
</div>
{visible.map(({ category, depth, count, hasChildren }) => {
const open = !collapsed.has(category.id);
return (
<div
className={`knowledge-section-row${selected === category.id ? " is-active" : ""}`}
key={category.id}
style={{ paddingLeft: depth * 16 }}
>
{hasChildren ? (
<button
aria-expanded={open}
aria-label={open
? t("ai.collapse_category", { name: category.name })
: t("ai.expand_category", { name: category.name })}
className={`knowledge-section-twist${open ? " is-open" : ""}`}
type="button"
onClick={() => toggle(category.id)}
>
<Icon name="chevronRight" size={13} strokeWidth={2.2} />
</button>
) : (
<i className="knowledge-section-twist" />
)}
<button className="knowledge-section-pick" type="button" onClick={() => onSelect(category.id)}>
<span>{category.name}</span>
<small>{count}</small>
</button>
</div>
);
})}
</nav>
{canManage && (
<div className="knowledge-sections-foot">
<button type="button" onClick={onManage}>
<Icon name="plus" size={14} strokeWidth={2} />{t("ai.add_category")}</button>
</div>
)}
</aside>
);
}
@@ -1,3 +1,4 @@
import { allPages } from "../../../shared/allPages";
import { api, apiUpload } from "../../../api/client";
import type { PagedPayload } from "../../../shared/usePagedResource";
import type {
@@ -18,21 +19,31 @@ import type {
KnowledgeUpdateRequest,
} from "./types";
function knowledgeListPath(filters: KnowledgeListFilters, page?: number): string {
function knowledgeListPath(filters: KnowledgeListFilters, page?: number, pageSize?: number): string {
const query = new URLSearchParams();
if (filters.category !== undefined) query.set("category", String(filters.category));
if (filters.isEnabled !== undefined) query.set("isEnabled", String(filters.isEnabled));
if (filters.search !== undefined && filters.search !== "") query.set("search", filters.search);
for (const agent of filters.agents ?? []) query.append("agent", String(agent));
if (page !== undefined) query.set("page", String(page));
if (pageSize !== undefined) query.set("pageSize", String(pageSize));
const suffix = query.toString();
return `/api/v1/ai/knowledge/${suffix ? `?${suffix}` : ""}`;
}
// Библиотека знаний приходит страницей: фильтры и ветка категорий отрабатывают
// на сервере (кадр KB1).
export function fetchKnowledgeList(filters: KnowledgeListFilters = {}, page = 1) {
return api<PagedPayload<KnowledgeItem>>(knowledgeListPath(filters, page));
export function fetchKnowledgeList(filters: KnowledgeListFilters = {}, page = 1, pageSize?: number) {
return api<PagedPayload<KnowledgeItem>>(knowledgeListPath(filters, page, pageSize));
}
// Потолок страницы на сервере (`api/pagination.py`, MAX_PAGE_SIZE).
const FULL_LIST_PAGE_SIZE = 100;
/** Вся библиотека целиком, страницами по сотне: диалогу выбора знаний агента
* нужен весь набор, а не первая страница списка. */
export function fetchAllKnowledge(filters: KnowledgeListFilters = {}): Promise<KnowledgeItem[]> {
return allPages((page) => fetchKnowledgeList(filters, page, FULL_LIST_PAGE_SIZE));
}
export function fetchKnowledgeItem(id: number) {
@@ -0,0 +1,50 @@
import { describe, expect, it } from "vitest";
import { t } from "../../../i18n";
import { planRows } from "./knowledgeImportPlan";
import { parseKnowledgeYaml } from "./parseKnowledgeYaml";
import type { KnowledgeCategory, KnowledgeItem } from "./types";
const categories: KnowledgeCategory[] = [
{ id: 1, name: "Root", parentId: null, sortOrder: 0, isSystem: false, knowledgeCount: 0 },
{ id: 2, name: "Child", parentId: 1, sortOrder: 0, isSystem: false, knowledgeCount: 0 },
{ id: 3, name: "Other", parentId: null, sortOrder: 0, isSystem: false, knowledgeCount: 0 },
{ id: 4, name: "Child", parentId: 3, sortOrder: 0, isSystem: false, knowledgeCount: 0 },
];
const existing: KnowledgeItem = {
id: 10, title: "Existing", description: "", category: categories[1],
isEnabled: true, attachments: [], agentsCount: 0, fragmentsCount: 0,
createdAt: "", updatedAt: "",
};
describe("knowledge import preview", () => {
it("allows an entirely new category tree from YAML", () => {
const parsed = parseKnowledgeYaml("documents:\n - title: New\n content: Text\n categoryPath: [New root, New child]");
const [row] = planRows(parsed, [], []);
expect(row.action).toBe("create");
expect(row.path).toBe("New root / New child");
expect(row.note).toBe(t("ai.import_category_creation"));
});
it("allows missing descendants and updates existing documents", () => {
const [row] = planRows({ documents: [
{ title: existing.title, content: "Text", categoryPath: ["Root", "New child"] },
] }, categories, [existing]);
expect(row.action).toBe("update");
expect(row.note).toBe(t("ai.import_category_creation"));
});
it("compares the full path instead of only the leaf name", () => {
const rows = planRows({ documents: [
{ title: existing.title, content: "Text", categoryPath: ["Other", "Child"] },
{ title: existing.title, content: "Text", categoryPath: ["Root", "Child"] },
] }, categories, [existing]);
expect(rows[0].note).toBe(t("ai.title_matched_text_will_replaced"));
expect(rows[1].note).toBe(t("ai.category_file_matches_current_one"));
});
it("keeps documents without a path importable", () => {
const [row] = planRows({ documents: [{ title: "New", content: "Text" }] }, [], []);
expect(row.action).toBe("create");
expect(row.note).toBe(t("ai.will_land_no_category"));
});
});
@@ -0,0 +1,62 @@
import { t } from "../../../i18n";
import type { KnowledgeCategory, KnowledgeItem } from "./model";
import type { ParsedKnowledgeYaml } from "./parseKnowledgeYaml";
export type ImportAction = "create" | "update" | "error";
export type ImportRow = {
document: ParsedKnowledgeYaml["documents"][number];
action: ImportAction;
path: string;
note: string;
};
export const ACTION_LABEL: Record<ImportAction, string> = {
create: t("ai.create"),
update: t("ai.update"),
error: t("ai.error"),
};
/** Разрешает путь категории в дерево организации: путь ищется по уровням. */
function resolvePath(categories: KnowledgeCategory[], path: string[]): number | null {
let parentId: number | null = null;
for (const name of path) {
const found: KnowledgeCategory | undefined = categories.find(
(category) => category.parentId === parentId && category.name === name,
);
if (!found) return null;
parentId = found.id;
}
return parentId;
}
export function planRows(
parsed: ParsedKnowledgeYaml,
categories: KnowledgeCategory[],
items: KnowledgeItem[],
): ImportRow[] {
const byTitle = new Map(items.map((item) => [item.title, item]));
return parsed.documents.map((document) => {
const existing = byTitle.get(document.title) ?? null;
const categoryId = document.categoryPath ? resolvePath(categories, document.categoryPath) : null;
const categoryNote = document.categoryPath && categoryId === null ? t("ai.import_category_creation") : "";
const path = document.categoryPath ? document.categoryPath.join(" / ") : t("ai.not_given");
if (!existing) {
return {
document,
action: "create",
path,
note: document.categoryPath ? categoryNote : t("ai.will_land_no_category"),
};
}
const samePlace = categoryId !== null && categoryId === existing.category.id;
return {
document,
action: "update",
path,
note: categoryNote || (samePlace
? t("ai.category_file_matches_current_one")
: t("ai.title_matched_text_will_replaced")),
};
});
}
@@ -10,6 +10,8 @@ export type CategoryRow = {
category: KnowledgeCategory;
depth: number;
count: number;
/** Есть ли вложенные категории: только такую строку можно свернуть. */
hasChildren: boolean;
};
/** Категории в порядке дерева с глубиной вложенности (левая панель KB1). */
@@ -26,7 +28,12 @@ export function categoryRows(categories: KnowledgeCategory[]): CategoryRow[] {
const rows: CategoryRow[] = [];
const walk = (parentId: number | null, depth: number) => {
(byParent.get(parentId) ?? []).forEach((category) => {
rows.push({ category, depth, count: category.knowledgeCount ?? 0 });
rows.push({
category,
depth,
count: category.knowledgeCount ?? 0,
hasChildren: (byParent.get(category.id) ?? []).length > 0,
});
walk(category.id, depth + 1);
});
};
@@ -34,6 +41,24 @@ export function categoryRows(categories: KnowledgeCategory[]): CategoryRow[] {
return rows;
}
/** Строки дерева без содержимого свёрнутых веток.
* Строки идут обходом в глубину, поэтому потомки свёрнутой категории — это
* всё, что следует за ней глубже её уровня, до первой строки того же уровня. */
export function visibleCategoryRows(
rows: CategoryRow[],
collapsed: ReadonlySet<number>,
): CategoryRow[] {
const visible: CategoryRow[] = [];
let hiddenBelow: number | null = null;
rows.forEach((row) => {
if (hiddenBelow !== null && row.depth > hiddenBelow) return;
hiddenBelow = null;
visible.push(row);
if (row.hasChildren && collapsed.has(row.category.id)) hiddenBelow = row.depth;
});
return visible;
}
/** Все потомки категории вместе с ней — выбор в дереве включает вложенные. */
export function categoryBranch(categories: KnowledgeCategory[], categoryId: number): Set<number> {
const branch = new Set<number>();
@@ -8,8 +8,7 @@ export type ParsedKnowledgeYaml = {
// Парсит YAML импорта знаний (SPEC-CHATBALLS-0012, плоский формат ADR-CHATBALLS-0023):
// { documents: [{ title, description?, content, categoryPath? }] }. Бросает
// Error с человекочитаемым сообщением при невалидном YAML или структуре.
// Путь категории разбирается, но не проверяется: несуществующий путь — это
// замечание в предпросмотре импорта (кадр KB8), а не поломка файла.
// Недостающие уровни пути сервер создаёт при применении импорта.
export function parseKnowledgeYaml(text: string): ParsedKnowledgeYaml {
let data: unknown;
try {
@@ -202,23 +202,16 @@
overflow-y: auto;
}
/* Строка дерева: слева шеврон ветки, дальше — кнопка выбора категории.
Кнопка в кнопку вложить нельзя, поэтому строка — контейнер, а фон выделения
и наведения принадлежит ей, а не кнопке выбора. */
.knowledge-section-row {
display: flex;
min-height: 32px;
align-items: center;
gap: 8px;
padding-top: 0;
padding-bottom: 0;
padding-right: 9px;
border: 0;
border-radius: 8px;
background: transparent;
color: var(--n-2);
font-family: inherit;
font-size: 13px;
font-weight: 500;
text-align: left;
cursor: pointer;
}
.knowledge-section-row:hover { background: var(--n-9); }
@@ -226,10 +219,55 @@
.knowledge-section-row.is-active {
background: var(--primary-bg);
color: var(--primary-text);
font-weight: 600;
}
.knowledge-section-row > span {
/* У категории без вложенных на месте шеврона пустое место той же ширины:
иначе названия соседних строк разъезжались бы по левому краю. */
.knowledge-section-twist {
display: inline-flex;
width: 18px;
height: 18px;
flex: none;
align-items: center;
justify-content: center;
margin-left: 3px;
border: 0;
border-radius: 5px;
background: transparent;
color: var(--n-5);
cursor: pointer;
transition: transform 140ms ease;
}
button.knowledge-section-twist:hover {
background: var(--n-8);
color: var(--n-3);
}
.knowledge-section-twist.is-open { transform: rotate(90deg); }
.knowledge-section-pick {
display: flex;
min-width: 0;
min-height: 32px;
flex: 1;
align-items: center;
gap: 8px;
padding: 0 9px 0 6px;
border: 0;
border-radius: 8px;
background: transparent;
color: inherit;
font-family: inherit;
font-size: 13px;
font-weight: 500;
text-align: left;
cursor: pointer;
}
.knowledge-section-row.is-active .knowledge-section-pick { font-weight: 600; }
.knowledge-section-pick > span {
min-width: 0;
flex: 1;
overflow: hidden;
@@ -237,13 +275,13 @@
white-space: nowrap;
}
.knowledge-section-row small {
.knowledge-section-pick small {
color: var(--n-5);
font-size: 11.5px;
font-weight: 600;
}
.knowledge-section-row.is-active small { color: var(--primary-text); }
.knowledge-section-row.is-active .knowledge-section-pick small { color: var(--primary-text); }
.knowledge-sections-foot {
flex: none;
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo, useState } from "react";
import { useDebounced } from "../../../shared/useDebounced";
import { usePagedResource } from "../../../shared/usePagedResource";
import { usePageSize } from "../../../shared/usePageSize";
import {
fetchKnowledgeCategories,
fetchKnowledgeList,
@@ -30,6 +31,10 @@ export function useKnowledgeLibrary() {
const [categoriesError, setCategoriesError] = useState(false);
const [selectedIds, setSelectedIds] = useState<Set<number>>(() => new Set());
const settledSearch = useDebounced(filters.search.trim());
// Размер страницы — часть набора: при его смене список возвращается на
// первую страницу, иначе седьмая страница по двадцать превратилась бы в
// седьмую по сто и оказалась бы за концом набора.
const { pageSize, setPageSize } = usePageSize("knowledge");
const reloadCategories = useCallback(async () => {
setCategoriesLoading(true);
@@ -50,10 +55,14 @@ export function useKnowledgeLibrary() {
isEnabled: filters.isEnabled,
search: settledSearch,
agents: filters.agents,
pageSize,
}),
[filters.agents, filters.category, filters.isEnabled, settledSearch],
[filters.agents, filters.category, filters.isEnabled, pageSize, settledSearch],
);
const load = useCallback(
(page: number) => fetchKnowledgeList(request, page, request.pageSize),
[request],
);
const load = useCallback((page: number) => fetchKnowledgeList(request, page), [request]);
const page = usePagedResource(load, request, t("ai.could_not_load_knowledge"));
useEffect(() => {
@@ -117,6 +126,8 @@ export function useKnowledgeLibrary() {
itemsLoading: page.loading,
page: page.page,
pageCount: page.pageCount,
pageSize: page.pageSize || pageSize,
setPageSize,
total: page.total,
setPage: page.setPage,
reload,
@@ -1,5 +1,6 @@
import { useEffect, useState, type ReactNode } from "react";
import { isManager } from "../../auth/access";
import { ChatMobileHeader } from "../../layout/ChatMobileHeader";
import { Hint } from "../../shared/Hint";
import { Icon } from "../../shared/icons";
@@ -49,6 +50,7 @@ export function ChatPage({
return (
<ConversationWorkspace
isOwner={user.role === "OWNER"}
canDelete={isManager(user)}
initialConversationId={initialConversationId}
scope={scope}
setScope={setScope}
@@ -63,11 +63,20 @@ export function AudioCallOverlay(props: Props) {
: builtStatus;
const onAccept = () => { void rtc.start(); };
// «Завершить» обязано сработать всегда. Токен доступа есть не в каждом
// состоянии (карточку звонка могли открыть из списка), а сам запрос может
// упасть — тогда звонок заканчивается вторым путём, по сессии оператора.
const finish = async () => {
if (!call || isTerminalCallStatus(call.status)) return;
const token = props.access?.accessToken;
if (!token || !call || isTerminalCallStatus(call.status)) return;
try { props.onCallChange(await endCallByAccess(token)); }
finally { rtc.stop(); }
try {
if (token) props.onCallChange(await endCallByAccess(token));
else props.onCancel();
} catch {
props.onCancel();
} finally {
rtc.stop();
}
};
const onEnd = () => { void finish(); };
const endAndClose = async () => {
@@ -6,23 +6,29 @@ import { Icon } from "../../shared/icons";
import { Button } from "../../shared/ui-controls";
import { t } from "../../i18n";
// Меню «⋯» над перепиской: вернуть в очередь, закрыть, спам, удалить.
// Удаление стирает диалог вместе с перепиской и доступно только владельцу и
// администратору — у оператора этого пункта в меню нет.
export function ConversationActions({
open,
canReturnQueue,
canDelete,
onClose,
onSpam,
onReturnQueue,
onArchive,
onDelete,
}: {
open: boolean;
canReturnQueue: boolean;
canDelete: boolean;
onClose: () => void;
onSpam: () => Promise<boolean>;
onReturnQueue: () => void;
onArchive: () => Promise<boolean>;
onDelete: () => Promise<boolean>;
}) {
const [confirmSpam, setConfirmSpam] = useState(false);
const [confirmArchive, setConfirmArchive] = useState(false);
const [confirmDelete, setConfirmDelete] = useState(false);
const [busy, setBusy] = useState(false);
if (!open) return null;
@@ -36,10 +42,10 @@ export function ConversationActions({
}
}
async function confirmArchiveAction() {
async function confirmDeleteAction() {
setBusy(true);
try {
if (await onArchive()) setConfirmArchive(false);
if (await onDelete()) setConfirmDelete(false);
} finally {
setBusy(false);
}
@@ -61,10 +67,12 @@ export function ConversationActions({
key: "spam",
label: <button className="danger" type="button" onClick={() => setConfirmSpam(true)}><Icon name="warning" size={15} />{t("conversations.mark_as_spam")}</button>,
},
{
key: "archive",
label: <button className="danger" type="button" onClick={() => setConfirmArchive(true)}><Icon name="trash" size={15} />{t("conversations.delete_conversation")}</button>,
},
...(canDelete
? [{
key: "delete",
label: <button className="danger" type="button" onClick={() => setConfirmDelete(true)}><Icon name="trash" size={15} />{t("conversations.delete_conversation")}</button>,
}]
: []),
];
return (
@@ -85,15 +93,15 @@ export function ConversationActions({
</>}
/>
<DecisionDialog
open={confirmArchive}
onClose={() => !busy && setConfirmArchive(false)}
open={confirmDelete}
onClose={() => !busy && setConfirmDelete(false)}
tone="danger"
icon="trash"
title={t("conversations.delete_conversation_2")}
description={t("conversations.conversation_moves_archive_leaves_lists")}
description={t("conversations.conversation_and_history_gone_forever")}
actions={<>
<Button variant="secondary" disabled={busy} onClick={() => setConfirmArchive(false)}>{t("common.cancel")}</Button>
<Button variant="danger-outline" icon="trash" disabled={busy} onClick={() => void confirmArchiveAction()}>{t("common.delete")}</Button>
<Button variant="secondary" disabled={busy} onClick={() => setConfirmDelete(false)}>{t("common.cancel")}</Button>
<Button variant="danger-outline" icon="trash" disabled={busy} onClick={() => void confirmDeleteAction()}>{t("common.delete")}</Button>
</>}
/>
</>
@@ -19,7 +19,7 @@ function fmtTime(value: string): string {
return fmt.time(value);
}
export function ConversationThread({ controlMode, dialog, detail, history, isOwner = false, onClaim, onRelease, onClose, onSpam, onReturnQueue, onArchive, onToggleContext, onMobileBack, onExpandList, viewerId = null }: { controlMode: ControlMode; dialog: ConversationListItem | null; detail: ApiConversation | null; history: ConversationHistory; isOwner?: boolean; onClaim: () => void; onRelease: () => void; onClose: () => void; onSpam: () => Promise<boolean>; onReturnQueue: () => void; onArchive: () => Promise<boolean>; onToggleContext?: () => void; onMobileBack?: () => void; onExpandList?: () => void; viewerId?: number | null }) {
export function ConversationThread({ controlMode, dialog, detail, history, isOwner = false, onClaim, onRelease, onClose, onSpam, onReturnQueue, canDelete = false, onDelete, onToggleContext, onMobileBack, onExpandList, viewerId = null }: { controlMode: ControlMode; dialog: ConversationListItem | null; detail: ApiConversation | null; history: ConversationHistory; isOwner?: boolean; onClaim: () => void; onRelease: () => void; onClose: () => void; onSpam: () => Promise<boolean>; onReturnQueue: () => void; canDelete?: boolean; onDelete: () => Promise<boolean>; onToggleContext?: () => void; onMobileBack?: () => void; onExpandList?: () => void; viewerId?: number | null }) {
const timelineRef = useRef<HTMLDivElement>(null);
const messages = history.messages;
// Лента держит низ при новых репликах и догружает предыдущие при подходе к
@@ -71,7 +71,7 @@ export function ConversationThread({ controlMode, dialog, detail, history, isOwn
{controlMode === "human" && <button className="sales-secondary-action" onClick={onRelease}>{t("conversations.hand_back_ai")}</button>}
{/* Кадр S2: на узком экране контекст-панель — выдвижная, кнопка в шапке. */}
{onToggleContext && <IconButton icon="user" label={t("conversations.conversation_context")} className="ctx-toggle" onClick={onToggleContext} />}
<ConversationActions open={detail?.lifecycle === "OPEN"} canReturnQueue={controlMode === "human"} onClose={onClose} onSpam={onSpam} onReturnQueue={onReturnQueue} onArchive={onArchive} />
<ConversationActions open={detail?.lifecycle === "OPEN"} canReturnQueue={controlMode === "human"} onClose={onClose} onSpam={onSpam} onReturnQueue={onReturnQueue} canDelete={canDelete} onDelete={onDelete} />
</div>
</div>
<div className="sales-timeline" ref={timelineRef} onScroll={onScroll}>
@@ -1,5 +1,6 @@
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import { ApiError } from "../../api/client";
import { CallOverlay } from "./CallOverlay";
import { Composer } from "./Composer";
import { ConversationThread } from "./ConversationThread";
@@ -9,10 +10,10 @@ import {
claimConversation,
closeConversation,
controlModeOf,
deleteConversation,
fetchConversation,
markConversationAsSpam,
releaseConversation,
setConversationArchived,
returnToQueue,
toConversationListItem,
type ApiConversation,
@@ -55,8 +56,10 @@ import { t } from "../../i18n";
// backend по группам (ADR-CHATBALLS-0043); страница параметризуется заголовком,
// placeholder поиска и правой панелью через render-prop. Список и история —
// серверные окна: ни то, ни другое целиком не запрашивается.
export function ConversationWorkspace({ isOwner = false, viewerId = null, listTitle, searchPlaceholder, renderContextPanel, mobileHeader, hint, initialConversationId, scope, setScope, counters, showScopeSwitcher = true }: {
export function ConversationWorkspace({ isOwner = false, canDelete = false, viewerId = null, listTitle, searchPlaceholder, renderContextPanel, mobileHeader, hint, initialConversationId, scope, setScope, counters, showScopeSwitcher = true }: {
isOwner?: boolean;
/** Удалять диалоги могут владелец и администратор (то же проверяет сервер). */
canDelete?: boolean;
listTitle?: string;
searchPlaceholder?: string;
renderContextPanel: (ctx: { dialog: ConversationListItem | null; detail: ApiConversation | null; applyConversation: (updated: ApiConversation) => void; startCall: ((kind: "AUDIO" | "VIDEO") => void) | null; closeContext: () => void; assignmentTimeoutMinutes?: number }) => ReactNode;
@@ -119,8 +122,17 @@ export function ConversationWorkspace({ isOwner = false, viewerId = null, listTi
setDetail(loaded);
setDetailError("");
}
} catch {
if (selectedIdRef.current === id) setDetailError(t("conversations.could_not_load_conversation"));
} catch (error) {
if (selectedIdRef.current !== id) return;
// Диалог удалили — возможно, другим администратором. Карточки больше
// нет, и держать выбор не на чем; список обновит событие инбокса.
if (error instanceof ApiError && error.status === 404) {
setSelectedId(null);
setDetail(null);
setDetailError("");
return;
}
setDetailError(t("conversations.could_not_load_conversation"));
}
}, []);
@@ -193,11 +205,21 @@ export function ConversationWorkspace({ isOwner = false, viewerId = null, listTi
const onReturnQueue = () => { void updateConversation(returnToQueue); };
const onClose = () => { void updateConversation(closeConversation); };
const onSpam = () => updateConversation(markConversationAsSpam);
const onArchive = async () => {
const done = await updateConversation((id) => setConversationArchived(id, true));
// Архивный диалог исчезает из списка — снимаем выбор.
if (done) setSelectedId(null);
return done;
// Удаление — не действие над диалогом, а его конец: обновлять нечего, из
// списка он уходит вместе с перепиской.
const onDelete = async () => {
if (selectedId == null) return false;
setActionError("");
try {
await deleteConversation(selectedId);
setDetail(null);
setSelectedId(null);
void list.refresh();
return true;
} catch (error) {
setActionError(error instanceof Error ? error.message : t("conversations.could_not_delete_conversation"));
return false;
}
};
return (
@@ -246,7 +268,7 @@ export function ConversationWorkspace({ isOwner = false, viewerId = null, listTi
{selectedDialog && (
<section className="sales-conversation enter-surface" key={selectedDialog.id}>
{ctxOpen && <button className="ctx-backdrop" type="button" aria-label={t("admin.close_panel")} onClick={() => setCtxOpen(false)} />}
<ConversationThread controlMode={controlMode} dialog={selectedDialog} detail={detail} history={history} isOwner={isOwner} onExpandList={listCollapsed ? () => setListCollapsed(false) : undefined} viewerId={viewerId} onClaim={onClaim} onRelease={onRelease} onClose={onClose} onSpam={onSpam} onReturnQueue={onReturnQueue} onArchive={onArchive} onToggleContext={() => setCtxOpen((open) => !open)} onMobileBack={() => setMobileDialogOpen(false)} />
<ConversationThread controlMode={controlMode} dialog={selectedDialog} detail={detail} history={history} isOwner={isOwner} onExpandList={listCollapsed ? () => setListCollapsed(false) : undefined} viewerId={viewerId} onClaim={onClaim} onRelease={onRelease} onClose={onClose} onSpam={onSpam} onReturnQueue={onReturnQueue} canDelete={canDelete} onDelete={onDelete} onToggleContext={() => setCtxOpen((open) => !open)} onMobileBack={() => setMobileDialogOpen(false)} />
{(detailError || actionError || history.errorText) && <div className="sales-conversation-error">{detailError || actionError || history.errorText}</div>}
<CallOverlay
open={callController.open}
@@ -2,8 +2,9 @@ import { Dropdown } from "antd";
import { useEffect, useState } from "react";
import { Icon } from "../../shared/icons";
import { SearchInput } from "../../shared/ui-controls";
import { assigneeMenuItems, initials, SmallAvatar } from "./assigneeOptions";
import { DialogLabels } from "./DialogLabels";
import { NoteSection } from "./DialogNoteSection";
import { assigneeMenuItems, SmallAvatar } from "./assigneeOptions";
import { WaitingBlock } from "./WaitingBlock";
import { PriorityBars } from "./DialogList";
import { useEmployeeDirectory } from "./useEmployeeDirectory";
@@ -11,17 +12,12 @@ import { statusFor } from "./data";
import {
agentColorOf,
controlModeOf,
createConversationLabel,
fetchConversationLabels,
groupColorOf,
setConversationArchived,
setConversationAssignee,
setConversationGroup,
setConversationLabels,
setConversationNote,
setConversationPriority,
type ApiConversation,
type ConversationLabelRef,
type ConversationPriority,
} from "./model";
import type { EmployeeGroupRef } from "../../types";
@@ -62,18 +58,11 @@ export function DialogControls({
const [busy, setBusy] = useState(false);
const [errorText, setErrorText] = useState("");
const [collapsed, setCollapsed] = useState(false);
const [labels, setLabels] = useState<ConversationLabelRef[]>([]);
const [newLabel, setNewLabel] = useState("");
useEffect(() => {
setNewLabel("");
setErrorText("");
}, [detail.id]);
useEffect(() => {
fetchConversationLabels().then(setLabels).catch(() => setLabels([]));
}, []);
async function run(action: () => Promise<ApiConversation>) {
setBusy(true);
setErrorText("");
@@ -86,30 +75,6 @@ export function DialogControls({
}
}
async function addLabel(id?: number) {
let labelId = id;
setBusy(true);
setErrorText("");
try {
if (labelId === undefined) {
const name = newLabel.trim();
if (!name) return;
const label = await createConversationLabel(name);
setLabels((current) => (current.some((item) => item.id === label.id) ? current : [...current, label]));
labelId = label.id;
setNewLabel("");
}
const ids = [...new Set([...detail.labels.map((item) => item.id), labelId])];
applyConversation(await setConversationLabels(detail.id, ids));
} catch (error) {
setErrorText(error instanceof Error ? error.message : t("conversations.could_not_add_label"));
} finally {
setBusy(false);
}
}
const assignedIds = new Set(detail.labels.map((item) => item.id));
const availableLabels = labels.filter((item) => !assignedIds.has(item.id));
const assignee = detail.assignedOperator;
const assigneeHere = assignee ? directory.employees.some((employee) => employee.id === assignee.id && employee.online) : false;
const assigneeLabel = assignee ? `${assignee.name}${viewerId != null && assignee.id === viewerId ? t("common.you_suffix") : ""}` : t("conversations.unassigned");
@@ -205,57 +170,7 @@ export function DialogControls({
</div>
</div>
<label className="ctx-label">{t("conversations.labels")}</label>
<div className="ctx-labels">
<Dropdown
disabled={busy}
trigger={["click"]}
overlayClassName="app-dropdown is-wide ctx-labels-menu"
menu={{
items: [
...availableLabels.map((label) => ({
key: label.id,
label: <button type="button" onClick={() => void addLabel(label.id)}><i className="ctx-dot is-square" style={{ background: label.color || "var(--n-5)" }} />{label.name}</button>,
})),
{
key: "new",
label: (
<div className="ctx-new-label" onClick={(event) => event.stopPropagation()}>
<input
placeholder={t("conversations.new_label")}
value={newLabel}
onChange={(event) => setNewLabel(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter") {
event.preventDefault();
void addLabel();
}
}}
/>
<button type="button" disabled={busy || !newLabel.trim()} onClick={() => void addLabel()}><Icon name="plus" size={13} /></button>
</div>
),
},
],
}}
>
<button type="button" className="ctx-add-label"><Icon name="plus" size={13} />{t("conversations.add")}</button>
</Dropdown>
{detail.labels.map((label) => (
<b className="ctx-label-chip" key={label.id}>
<i style={{ background: label.color || "var(--n-5)" }} />
{label.name}
<button
aria-label={t("conversations.remove_label", { name: label.name })}
disabled={busy}
type="button"
onClick={() => void run(() => setConversationLabels(detail.id, detail.labels.filter((item) => item.id !== label.id).map((item) => item.id)))}
>
×
</button>
</b>
))}
</div>
<DialogLabels detail={detail} busy={busy} setBusy={setBusy} setErrorText={setErrorText} applyConversation={applyConversation} run={run} />
<div className="ctx-meta-row">
<span>{detail.waitingSince ? t("conversations.in_queue_since") : t("conversations.started")}</span>
@@ -270,36 +185,6 @@ export function DialogControls({
);
}
function NoteSection({ detail, busy, onSave }: { detail: ApiConversation; busy: boolean; onSave: (note: string) => Promise<void> }) {
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState(detail.note);
useEffect(() => {
setEditing(false);
setDraft(detail.note);
}, [detail.id, detail.note]);
return (
<section className="ctx-section is-note">
<div className="ctx-section-head">
<h4>{t("conversations.note")}</h4>
<button type="button" aria-label={t("conversations.edit_note")} onClick={() => setEditing(true)}><Icon name="edit" size={14} /></button>
</div>
{editing ? (
<div className="ctx-note is-editing">
<textarea autoFocus disabled={busy} placeholder={t("conversations.internal_note_customer_does_not")} rows={3} value={draft} onChange={(event) => setDraft(event.target.value)} />
<div className="ctx-note-actions">
<button type="button" disabled={busy} onClick={() => { setDraft(detail.note); setEditing(false); }}>{t("common.cancel")}</button>
<button type="button" className="primary" disabled={busy} onClick={() => void onSave(draft).then(() => setEditing(false))}>{t("common.save")}</button>
</div>
</div>
) : (
<div className={`ctx-note ${detail.note ? "" : "is-empty"}`} onClick={() => setEditing(true)}>{detail.note || t("conversations.no_notes")}</div>
)}
</section>
);
}
// «сегодня, 17:02» · «вчера, 11:05» · «12 авг, 09:30»
export function startedLabel(iso: string): string {
const date = new Date(iso);
@@ -312,21 +197,3 @@ export function startedLabel(iso: string): string {
return fmt.shortDateTime(date);
}
export function archiveConversationAction(
detail: ApiConversation,
applyConversation: (updated: ApiConversation) => void,
onError: (text: string) => void,
): Promise<boolean> {
return setConversationArchived(detail.id, true)
.then((updated) => {
applyConversation(updated);
return true;
})
.catch((error) => {
onError(error instanceof Error ? error.message : t("conversations.could_not_delete_conversation"));
return false;
});
}
// Аватар 22px в поле «Ответственный»: фото сотрудника или инициалы.
@@ -0,0 +1,110 @@
import { Dropdown } from "antd";
import { useEffect, useState } from "react";
import { t } from "../../i18n";
import { Icon } from "../../shared/icons";
import { createConversationLabel, fetchConversationLabels, setConversationLabels,
type ApiConversation, type ConversationLabelRef } from "./model";
import "./dialogLabels.css";
export function DialogLabels({ detail, busy, setBusy, setErrorText, applyConversation, run }: {
detail: ApiConversation;
busy: boolean;
setBusy: (busy: boolean) => void;
setErrorText: (error: string) => void;
applyConversation: (updated: ApiConversation) => void;
run: (action: () => Promise<ApiConversation>) => Promise<void>;
}) {
const [labels, setLabels] = useState<ConversationLabelRef[]>([]);
const [newLabel, setNewLabel] = useState("");
useEffect(() => { setNewLabel(""); }, [detail.id]);
useEffect(() => {
fetchConversationLabels().then(setLabels).catch((error) => {
setErrorText(error instanceof Error ? error.message : t("conversations.could_not_add_label"));
});
}, [setErrorText]);
async function addLabel(id?: number) {
if (busy) return;
let labelId = id;
setBusy(true);
setErrorText("");
try {
if (labelId === undefined) {
const name = newLabel.trim();
if (!name) return;
const label = await createConversationLabel(name);
setLabels((current) => (current.some((item) => item.id === label.id) ? current : [...current, label]));
labelId = label.id;
setNewLabel("");
}
const ids = [...new Set([...detail.labels.map((item) => item.id), labelId])];
applyConversation(await setConversationLabels(detail.id, ids));
} catch (error) {
setErrorText(error instanceof Error ? error.message : t("conversations.could_not_add_label"));
} finally {
setBusy(false);
}
}
const assignedIds = new Set(detail.labels.map((item) => item.id));
const availableLabels = labels.filter((item) => !assignedIds.has(item.id));
return (
<>
<label className="ctx-label">{t("conversations.labels")}</label>
<div className="ctx-labels">
<Dropdown
disabled={busy}
trigger={["click"]}
overlayClassName="app-dropdown is-wide ctx-labels-menu"
menu={{
items: [
...availableLabels.map((label) => ({
key: label.id,
label: <button type="button" onClick={() => void addLabel(label.id)}><i className="ctx-dot is-square" style={{ background: label.color || "var(--n-5)" }} />{label.name}</button>,
})),
{
key: "new",
label: (
<div className="ctx-new-label" onClick={(event) => event.stopPropagation()}>
<input
aria-label={t("conversations.new_label")}
disabled={busy}
placeholder={t("conversations.new_label")}
value={newLabel}
onChange={(event) => setNewLabel(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter") {
event.stopPropagation();
event.preventDefault();
void addLabel();
}
}}
/>
<button type="button" aria-label={t("conversations.add")} disabled={busy || !newLabel.trim()} onClick={() => void addLabel()}><Icon name="plus" size={13} /></button>
</div>
),
},
],
}}
>
<button type="button" className="ctx-add-label"><Icon name="plus" size={13} />{t("conversations.add")}</button>
</Dropdown>
{detail.labels.map((label) => (
<b className="ctx-label-chip" key={label.id}>
<i style={{ background: label.color || "var(--n-5)" }} />
{label.name}
<button
aria-label={t("conversations.remove_label", { name: label.name })}
disabled={busy}
type="button"
onClick={() => void run(() => setConversationLabels(detail.id, detail.labels.filter((item) => item.id !== label.id).map((item) => item.id)))}
>
×
</button>
</b>
))}
</div>
</>
);
}
@@ -90,7 +90,7 @@ export function DialogList({ title = t("common.conversations"), searchPlaceholde
{hint}
{/* Что значат две вкладки ожидания — прямо там, где их выбирают. */}
{(listTab === "queue" || listTab === "onMe") && (
<div className="sales-wait-note">{t("conversations.queue_tabs_note")}</div>
<div className="sales-dialog-tabs-note">{t("conversations.queue_tabs_note")}</div>
)}
<div className="sales-dialog-list-body" onScroll={onScroll}>
{errorText && <div className="sales-wait-note sales-load-error">{errorText}</div>}
@@ -0,0 +1,34 @@
import { useEffect, useState } from "react";
import { t } from "../../i18n";
import { Icon } from "../../shared/icons";
import type { ApiConversation } from "./model";
export function NoteSection({ detail, busy, onSave }: { detail: ApiConversation; busy: boolean; onSave: (note: string) => Promise<void> }) {
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState(detail.note);
useEffect(() => {
setEditing(false);
setDraft(detail.note);
}, [detail.id, detail.note]);
return (
<section className="ctx-section is-note">
<div className="ctx-section-head">
<h4>{t("conversations.note")}</h4>
<button type="button" aria-label={t("conversations.edit_note")} onClick={() => setEditing(true)}><Icon name="edit" size={14} /></button>
</div>
{editing ? (
<div className="ctx-note is-editing">
<textarea autoFocus disabled={busy} placeholder={t("conversations.internal_note_customer_does_not")} rows={3} value={draft} onChange={(event) => setDraft(event.target.value)} />
<div className="ctx-note-actions">
<button type="button" disabled={busy} onClick={() => { setDraft(detail.note); setEditing(false); }}>{t("common.cancel")}</button>
<button type="button" className="primary" disabled={busy} onClick={() => void onSave(draft).then(() => setEditing(false))}>{t("common.save")}</button>
</div>
</div>
) : (
<div className={`ctx-note ${detail.note ? "" : "is-empty"}`} onClick={() => setEditing(true)}>{detail.note || t("conversations.no_notes")}</div>
)}
</section>
);
}
@@ -66,11 +66,19 @@ export function VideoCallOverlay(props: Props) {
const channel = providerMeta[props.dialog.channel];
const subtitle = subtitleFor(mode, call, status);
const mediaCaption = rtc.mediaIssue === "devices" ? t("conversations.no_access_camera_microphone") : rtc.mediaIssue === "video" ? t("conversations.camera_unavailable") : t("conversations.camera_off");
// «Завершить» обязано сработать всегда: нет токена доступа или запрос не
// прошёл — звонок заканчивается вторым путём, по сессии оператора.
const finish = async () => {
if (!call || TERMINAL[call.status]) return;
const token = props.access?.accessToken;
if (!token || !call || TERMINAL[call.status]) return;
try { props.onCallChange(await endCallByAccess(token)); }
finally { rtc.stop(); }
try {
if (token) props.onCallChange(await endCallByAccess(token));
else props.onCancel();
} catch {
props.onCancel();
} finally {
rtc.stop();
}
};
const endAndClose = async () => {
if (mode === "active" || mode === "reconnecting" || mode === "connecting" || mode === "precall") await finish();
@@ -1,5 +1,7 @@
import { useEffect, useRef, useState } from "react";
import { voiceWaveHeights } from "@chatballs/ui";
import { Icon } from "../../shared/icons";
import { resolveApiUrl } from "../../api/client";
import { transcribeMessage, type ApiMessage } from "./model";
@@ -13,18 +15,6 @@ export function formatDuration(totalSeconds: number): string {
return `${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, "0")}`;
}
// Детерминированная волна из id: реальная амплитуда не хранится, рисунок
// стабилен между рендерами (как в макете).
function waveHeights(seed: number, bars = 32): number[] {
const heights: number[] = [];
let value = seed || 1;
for (let index = 0; index < bars; index += 1) {
value = (value * 1103515245 + 12345) % 2147483648;
heights.push(4 + (value % 17));
}
return heights;
}
export function VoiceMessage({ message: incoming }: { message: ApiMessage }) {
// Локальная копия: расшифровка приходит из POST-ответа сразу, а фоновый
// поллинг detail затем подтверждает её с сервера.
@@ -38,7 +28,7 @@ export function VoiceMessage({ message: incoming }: { message: ApiMessage }) {
const [transcribing, setTranscribing] = useState(false);
const [showTranscript, setShowTranscript] = useState(true);
const [errorText, setErrorText] = useState("");
const bars = waveHeights(message.id);
const bars = voiceWaveHeights(message.id);
useEffect(() => () => {
audioRef.current?.pause();
@@ -0,0 +1,27 @@
/* Меню меток: список готовых меток и строка «новая метка».
Меню antd — вертикальное, а значит с `overflow-y: auto`; по правилам CSS
вторая ось тогда тоже перестаёт быть `visible` и любой лишний пиксель строки
ввода превращается в горизонтальную полосу прокрутки под полем. Полосы здесь
быть не должно: строка обязана вписываться в ширину меню. */
.app-dropdown.ctx-labels-menu .ant-dropdown-menu {
overflow-x: hidden;
}
.app-dropdown.ctx-labels-menu .ant-dropdown-menu-title-content {
min-width: 0;
}
.app-dropdown.ctx-labels-menu .ctx-new-label {
box-sizing: border-box;
max-width: 100%;
}
/* Keep the inline add button from inheriting full-width menu item styles. */
.app-dropdown.ctx-labels-menu .ctx-new-label button {
width: 30px;
min-height: 30px;
padding: 0;
color: #ffffff;
background: var(--primary);
}
@@ -351,6 +351,9 @@ export const setConversationLabels = (id: number, labelIds: number[]) =>
conversationAction(id, "labels", { labelIds });
export const setConversationArchived = (id: number, archived: boolean) =>
conversationAction(id, "archive", { archived });
/** Удалить диалог вместе с перепиской. Право — у владельца и администратора. */
export const deleteConversation = (id: number) =>
api<void>(`/api/v1/conversations/${id}/`, { method: "DELETE" });
export const setConversationGroup = (id: number, groupId: number | null) =>
conversationAction(id, "group", { groupId });
export const setConversationAssignee = (id: number, userId: number | null) =>
@@ -64,7 +64,7 @@ export function HelpCenterApp() {
);
}
if (!manifest || !themeReady) {
return <main className="help-boot-loading"><Loader size={44} /></main>;
return <main className="help-boot-loading"><Loader size={72} /></main>;
}
return route.articleSlug
? <HelpArticle manifest={manifest} articleSlug={route.articleSlug} />
@@ -42,11 +42,17 @@ export function HelpLayout({
</header>
<main>{children}</main>
<footer className="help-footer">
<a className="help-footer-brand" href={homeHref}>
<span className="help-brand-mark"><LogoIcon /></span>
<span>{portal.name}</span>
</a>
<span>{t("portals.knowledge_base_support")}</span>
<div className="help-footer-row">
<a className="help-footer-brand" href={homeHref}>
<span className="help-brand-mark"><LogoIcon /></span>
<span>{portal.name}</span>
</a>
<span>{t("portals.knowledge_base_support")}</span>
</div>
<span className="help-footer-powered">
{t("portals.powered_by")}{" "}
<a href="https://chatballs.ru" target="_blank" rel="noopener noreferrer">Chatballs</a>
</span>
</footer>
{portal.webWidgetKey && <PortalWebWidget widgetKey={portal.webWidgetKey} />}
</div>
@@ -153,17 +153,37 @@ html.help-center-document body {
}
.help-footer {
display: flex;
display: grid;
width: min(1024px, calc(100% - 48px));
margin: 80px auto 0;
padding: 34px 0 42px;
align-items: center;
justify-content: space-between;
gap: 18px;
border-top: 1px solid var(--help-line-soft);
color: var(--help-text-muted);
font-size: 14px;
}
.help-footer-row {
display: flex;
align-items: center;
justify-content: space-between;
}
.help-footer-powered {
font-size: 12px;
opacity: 0.8;
}
.help-footer-powered a {
color: inherit;
text-decoration: underline;
text-underline-offset: 2px;
}
.help-footer-powered a:hover {
color: var(--help-ink);
}
.help-footer-brand {
gap: 9px;
font-size: 16px;
@@ -256,8 +276,7 @@ html.help-center-document body {
color: var(--help-text-muted);
}
.help-fatal,
.help-boot-loading {
.help-fatal {
display: grid;
min-height: 100vh;
place-content: center;
@@ -115,10 +115,14 @@
}
.help-footer {
gap: 14px;
margin-top: 54px;
}
.help-footer-row {
display: grid;
justify-content: start;
gap: 12px;
margin-top: 54px;
}
.help-support-launcher {
@@ -1,16 +1,28 @@
import { Icon } from "../../shared/icons";
import { useDraggablePosition } from "../../shared/useDraggablePosition";
import { t } from "../../i18n";
import { useOnboarding } from "./useOnboarding";
// Пилюля возврата в правом нижнем углу рабочей области. Появляется, когда
// окно закрыто, тур не идёт, а настроено ещё не всё: закрыть онбординг можно
// одним кликом, но дорогу назад стоит оставить на виду.
//
// Пилюлю перетаскивают: в правом нижнем углу она закрывает то кнопку отправки,
// то последнее сообщение ленты. Место, куда её отвели, помнит браузер.
export function OnboardingLauncher() {
const onboarding = useOnboarding();
const drag = useDraggablePosition<HTMLButtonElement>("onboarding-launcher", { right: 24, bottom: 24 });
if (!onboarding?.showLauncher) return null;
return (
<button className="ob-launcher" type="button" onClick={onboarding.open}>
<button
className={`ob-launcher${drag.dragging ? " is-dragging" : ""}`}
ref={drag.ref}
style={drag.style}
type="button"
onPointerDown={drag.onPointerDown}
onClick={() => { if (!drag.wasDragged()) onboarding.open(); }}
>
<Icon name="sparkles" size={17} strokeWidth={1.9} />
{t("onboarding.title")}
<small>{t("onboarding.progress_short", { done: onboarding.doneCount, total: onboarding.steps.length })}</small>
@@ -887,11 +887,16 @@
/* ── Точки возврата ─────────────────────────────────────────────────────── */
/* Положение задаётся инлайном (`useDraggablePosition`): пилюлю перетаскивают,
и её место помнит браузер. Значения в CSS остаются запасными — до первого
рендера и на случай пустого хранилища. */
.ob-launcher {
position: absolute;
position: fixed;
right: 24px;
bottom: 24px;
z-index: 20;
touch-action: none;
user-select: none;
height: 44px;
display: inline-flex;
align-items: center;
@@ -910,6 +915,11 @@
.ob-launcher:hover { background: var(--primary-bg); }
.ob-launcher.is-dragging {
cursor: grabbing;
box-shadow: 0 14px 34px -8px rgba(16, 18, 22, 0.42);
}
.ob-launcher small {
padding: 1px 7px;
border-radius: var(--radius-pill);
@@ -129,6 +129,21 @@
line-height: 12px;
}
/* Подсказка про две вкладки ожидания. Своя строка под вкладками, а не общая
заметка `.sales-wait-note`: у той отрицательный отступ сверху (она стоит в
ленте переписки), и здесь он затягивал текст прямо на строку вкладок. */
.sales-dialog-tabs-note {
flex: none;
margin: 0;
padding: 9px 14px 10px;
border-bottom: 1px solid var(--n-8);
color: var(--n-5);
font-size: 11.5px;
line-height: 1.45;
text-align: center;
text-wrap: pretty;
}
.sales-dialog-list-body {
flex: 1;
min-height: 0;
@@ -3,7 +3,9 @@ import { useState } from "react";
import { DecisionDialog } from "../../shared/DecisionDialog";
import { Icon, LogoSpinner } from "../../shared/icons";
import { Button } from "../../shared/ui-controls";
import { installUpdate } from "./api";
import { installUpdate, type UpdateInfo } from "./api";
import { useUpdatesCardVisible } from "./cardPresence";
import { STAGES, clockLabel, stageNumber, stageOf, stageProgress, useElapsed } from "./progress";
import { useUpdateInfo } from "./useUpdateInfo";
import { t } from "../../i18n";
@@ -12,47 +14,50 @@ import { t } from "../../i18n";
// что у установки демо-данных (ADR-CHATBALLS-0049).
export function UpdateBanner({ enabled }: { enabled: boolean }) {
const { info, setInfo, installing, unreachable, finished } = useUpdateInfo(enabled);
const { info, setInfo, installing, unreachable, finished, forget, markPending, targetVersion, startedAt } =
useUpdateInfo(enabled);
const [dismissed, setDismissed] = useState<string | null>(null);
const [confirming, setConfirming] = useState(false);
const [busy, setBusy] = useState(false);
const [errorText, setErrorText] = useState("");
const cardVisible = useUpdatesCardVisible();
if (!enabled) return null;
// Карточка «Обновления» открыта — она и рассказывает всё то же самое.
if (!enabled || cardVisible) return null;
async function install() {
setBusy(true);
setErrorText("");
// Полоса хода появляется по нажатию, а не по ответу сервера: запрос идёт
// около секунды, и всё это время экран не должен выглядеть безучастным.
markPending(info?.latestVersion ?? null);
try {
setInfo(await installUpdate());
setConfirming(false);
} catch (error) {
forget();
setErrorText(error instanceof Error ? error.message : t("common.could_not_complete_action"));
} finally {
setBusy(false);
}
}
const version = info?.install.version ?? info?.latestVersion ?? "";
const version = targetVersion ?? info?.latestVersion ?? "";
if (installing) {
const stage = unreachable ? "restarting" : info?.install.message || "starting";
const stageKey = (["downloading", "pulling", "restarting"].includes(stage) ? stage : "starting") as "downloading" | "pulling" | "restarting" | "starting";
return (
<div className="demo-install-banner update-banner">
<LogoSpinner size={17} />
<span>{t("updates.installing", { version })} {t(`updates.stage_${stageKey}`)}</span>
</div>
<UpdateBannerProgress info={info} unreachable={unreachable} version={version} startedAt={startedAt} />
);
}
if (finished && info?.install.status === "DONE" && dismissed !== `done:${version}`) {
if (finished) {
const installed = info?.currentVersion ?? version;
return (
<div className="demo-install-banner update-banner is-done">
<Icon name="check" size={15} strokeWidth={2.2} />
<span>{t("updates.done", { version })}</span>
<button type="button" onClick={() => window.location.reload()}>{t("common.show")}</button>
<button className="demo-install-close" type="button" onClick={() => setDismissed(`done:${version}`)} aria-label={t("common.hide")}>
<span>{t("updates.done", { version: installed })}</span>
<button type="button" onClick={() => window.location.reload()}>{t("updates.reload")}</button>
<button className="demo-install-close" type="button" onClick={forget} aria-label={t("common.hide")}>
<Icon name="close" size={14} strokeWidth={2} />
</button>
</div>
@@ -106,3 +111,44 @@ export function UpdateBanner({ enabled }: { enabled: boolean }) {
</>
);
}
// Ход установки в полосе: место есть только на одну строку, поэтому шаги
// сворачиваются в тонкую линию с подписью «шаг N из M» и временем — то же
// знание, что и в списке шагов карточки «Обновления».
function UpdateBannerProgress({
info,
unreachable,
version,
startedAt,
}: {
info: UpdateInfo | null;
unreachable: boolean;
version: string;
startedAt: string | null;
}) {
const stage = stageOf(info, unreachable);
const elapsed = useElapsed(startedAt, true);
return (
<div
className="demo-install-banner update-banner"
role="progressbar"
aria-valuemin={0}
aria-valuemax={STAGES.length}
aria-valuenow={stageNumber(stage)}
aria-valuetext={`${t("updates.installing", { version })} ${t(`updates.stage_${stage}`)}`}
>
<LogoSpinner size={17} />
<span>
{t("updates.installing", { version })} {t(`updates.stage_${stage}`)}
</span>
<span className="update-banner-bar" aria-hidden="true">
<span style={{ width: `${stageProgress(stage)}%` }} />
</span>
<span className="update-banner-step">
{t("updates.progress_step", { step: String(stageNumber(stage)), total: String(STAGES.length) })} · {clockLabel(elapsed)}
</span>
</div>
);
}
@@ -0,0 +1,51 @@
import { Icon, LogoSpinner } from "../../shared/icons";
import type { UpdateInfo } from "./api";
import { STAGES, clockLabel, stageNumber, stageOf, useElapsed } from "./progress";
import { t } from "../../i18n";
// Ход установки списком шагов. Сколько времени займёт обновление, заранее не
// знает никто (образы тянутся с разной скоростью), поэтому полосы с процентами
// здесь нет: человеку показывается, какой шаг идёт сейчас, сколько их всего и
// сколько уже идёт установка. Этого достаточно, чтобы понять, что всё живо.
export function UpdateProgress({
info,
unreachable,
version,
startedAt,
}: {
info: UpdateInfo | null;
unreachable: boolean;
version: string;
startedAt: string | null;
}) {
const stage = stageOf(info, unreachable);
const current = stageNumber(stage);
const elapsed = useElapsed(startedAt, true);
return (
<div className="update-progress" role="status" aria-live="polite">
<div className="update-progress-head">
<LogoSpinner size={18} />
<strong>{t("updates.progress_title", { version })}</strong>
<span className="update-progress-clock">
{t("updates.progress_step", { step: String(current), total: String(STAGES.length) })} · {clockLabel(elapsed)}
</span>
</div>
<ol className="update-progress-steps">
{STAGES.map((item, index) => {
const state = index + 1 < current ? "is-done" : index + 1 === current ? "is-current" : "is-waiting";
return (
<li key={item} className={`update-progress-step ${state}`}>
<span className="update-progress-mark" aria-hidden="true">
{state === "is-done" && <Icon name="check" size={11} strokeWidth={2.6} />}
</span>
<span>{t(`updates.step_${item}`)}</span>
</li>
);
})}
</ol>
<p className="update-progress-hint">{t("updates.progress_hint")}</p>
</div>
);
}
@@ -1,19 +1,27 @@
import { useState } from "react";
import { Icon } from "../../shared/icons";
import { Button } from "../../shared/ui-controls";
import { shortDateTime } from "../../shared/utils";
import { checkUpdates, installUpdate } from "./api";
import { useUpdatesCardPresence } from "./cardPresence";
import { UpdateProgress } from "./UpdateProgress";
import { useUpdateInfo } from "./useUpdateInfo";
import { t } from "../../i18n";
// Карточка «Обновления» в разделе «Платформа»: версия установки, последняя
// версия на канале релизов, проверка и установка по кнопке. Сама установка
// идёт снаружи приложения (ADR-CHATBALLS-0049), здесь только её состояние.
// идёт снаружи приложения (ADR-CHATBALLS-0049), здесь только её состояние —
// но состояние подробное: обновление перезапускает сервисы, и всё это время
// человек должен видеть, что идёт и на каком оно шаге.
export function UpdatesCard({ canManage }: { canManage: boolean }) {
const { info, setInfo, installing, unreachable } = useUpdateInfo(true);
const { info, setInfo, installing, unreachable, finished, forget, markPending, targetVersion, startedAt } =
useUpdateInfo(true);
const [busy, setBusy] = useState(false);
const [errorText, setErrorText] = useState("");
// Пока карточка на экране, полоса наверху о том же не говорит.
useUpdatesCardPresence();
async function run(action: () => Promise<typeof info>) {
setBusy(true);
@@ -28,6 +36,13 @@ export function UpdatesCard({ canManage }: { canManage: boolean }) {
}
}
function install() {
// Прогресс появляется по нажатию, а не по ответу сервера: запрос на
// установку идёт секунду, и всё это время экран не должен молчать.
markPending(info?.latestVersion ?? null);
void run(installUpdate);
}
if (!info && !unreachable) return null;
const latest = info?.latestVersion;
@@ -61,20 +76,36 @@ export function UpdatesCard({ canManage }: { canManage: boolean }) {
{info?.checkError && <div className="settings-section-error">{t("updates.check_error", { error: info.checkError })}</div>}
{errorText && <div className="settings-section-error">{errorText}</div>}
{installing && (
<p className="settings-card-note">{t("updates.installing", { version: info?.install.version ?? latest ?? "" })} {t(`updates.stage_${unreachable ? "restarting" : (["downloading", "pulling", "restarting"].includes(info?.install.message ?? "") ? info?.install.message : "starting") as "downloading" | "pulling" | "restarting" | "starting"}`)}</p>
<UpdateProgress
info={info}
unreachable={unreachable}
version={targetVersion ?? latest ?? ""}
startedAt={startedAt}
/>
)}
{finished && (
<div className="update-done" role="status">
<Icon name="check" size={15} strokeWidth={2.2} />
<div>
<strong>{t("updates.done_title", { version: info?.currentVersion ?? targetVersion ?? "" })}</strong>
<small>{t("updates.done_hint")}</small>
</div>
<Button variant="primary" onClick={() => window.location.reload()}>{t("updates.reload")}</Button>
<Button variant="secondary" onClick={forget}>{t("common.hide")}</Button>
</div>
)}
{!installing && status === "FAILED" && info?.install.version && (
<div className="settings-section-error">{t("updates.failed", { version: info.install.version })}{info.install.message ? `: ${info.install.message}` : ""}</div>
)}
{!installing && info?.available && (
{!installing && !finished && info?.available && (
<div className="settings-card-actions">
{canManage
? <Button variant="primary" disabled={busy || !info.updaterOnline} onClick={() => void run(installUpdate)}>{t("updates.install_version", { version: latest ?? "" })}</Button>
? <Button variant="primary" disabled={busy || !info.updaterOnline} onClick={install}>{t("updates.install_version", { version: latest ?? "" })}</Button>
: <span className="settings-card-note">{t("updates.available_title", { version: latest ?? "" })}</span>}
{!info.updaterOnline && <span className="settings-card-note">{t("updates.updater_offline")}</span>}
</div>
)}
{!installing && info && !info.available && latest && !info.checkError && (
{!installing && !finished && info && !info.available && latest && !info.checkError && (
<p className="settings-card-note">{t("updates.up_to_date")}</p>
)}
</div>
@@ -0,0 +1,33 @@
import { useEffect, useSyncExternalStore } from "react";
// Полоса поверх рабочей области и карточка «Обновления» рассказывают одно и то
// же. Когда карточка на экране, полоса молчит: два сообщения об одном событии
// рядом — это не «заметнее», а шум, и человек перестаёт читать оба.
let mounted = 0;
const listeners = new Set<() => void>();
function subscribe(listener: () => void): () => void {
listeners.add(listener);
return () => listeners.delete(listener);
}
/** Карточка на экране, пока смонтирована. */
export function useUpdatesCardPresence(): void {
useEffect(() => {
mounted += 1;
listeners.forEach((listener) => listener());
return () => {
mounted -= 1;
listeners.forEach((listener) => listener());
};
}, []);
}
export function useUpdatesCardVisible(): boolean {
return useSyncExternalStore(
subscribe,
() => mounted > 0,
() => false,
);
}
@@ -0,0 +1,60 @@
import { useEffect, useState } from "react";
import type { UpdateInfo } from "./api";
// Ход установки для интерфейса. Сервис обновления пишет стадию словом в
// status.json (starting → downloading → pulling → restarting → updated), и это
// единственное, что о ходе вообще известно: процента у docker pull здесь нет.
// Поэтому прогресс показывается стадиями — человек видит, что именно идёт
// сейчас и сколько таких шагов осталось, а не безымянную полосу.
export const STAGES = ["starting", "downloading", "pulling", "restarting"] as const;
export type Stage = (typeof STAGES)[number];
function isStage(value: string): value is Stage {
return (STAGES as readonly string[]).includes(value);
}
/**
* Текущая стадия.
*
* Молчание бэкенда во время установки — это и есть перезапуск: он сам входит в
* перезапускаемый стек и последнюю стадию сообщить уже не может.
*/
export function stageOf(info: UpdateInfo | null, unreachable: boolean): Stage {
if (unreachable) return "restarting";
const message = info?.install.message ?? "";
return isStage(message) ? message : "starting";
}
export function stageNumber(stage: Stage): number {
return STAGES.indexOf(stage) + 1;
}
/** Доля выполненного: последняя стадия не доводится до края, пока не готово. */
export function stageProgress(stage: Stage): number {
return Math.round(((stageNumber(stage) - 0.35) / STAGES.length) * 100);
}
/** Секунды с момента запуска установки; тикает раз в секунду. */
export function useElapsed(startedAt: string | null, running: boolean): number {
const [now, setNow] = useState(() => Date.now());
useEffect(() => {
if (!running) return undefined;
const timer = window.setInterval(() => setNow(Date.now()), 1000);
return () => window.clearInterval(timer);
}, [running]);
if (!startedAt) return 0;
const started = new Date(startedAt).getTime();
if (Number.isNaN(started)) return 0;
return Math.max(0, Math.floor((now - started) / 1000));
}
/** м:сс — установка идёт минуты, часы здесь не нужны. */
export function clockLabel(seconds: number): string {
const minutes = Math.floor(seconds / 60);
return `${minutes}:${String(seconds % 60).padStart(2, "0")}`;
}
@@ -0,0 +1,177 @@
/* Ход обновления установки. Обновление перезапускает сервисы и длится минуты,
поэтому его состояние показывается не строкой в общем потоке, а отдельным
блоком: видно, какой шаг идёт, сколько их всего и сколько уже длится. */
.update-progress {
display: flex;
flex-direction: column;
gap: 10px;
margin-top: 14px;
padding: 14px 16px;
border: 1px solid var(--primary-border);
border-radius: var(--radius-xl);
background: var(--primary-bg);
}
.update-progress-head {
display: flex;
align-items: center;
gap: 9px;
color: var(--primary-text);
font-size: 13.5px;
}
.update-progress-head strong {
flex: 1;
min-width: 0;
font-weight: 600;
}
.update-progress-clock {
flex: none;
color: var(--text-secondary);
font-size: 12.5px;
font-variant-numeric: tabular-nums;
}
.update-progress-steps {
display: flex;
flex-direction: column;
gap: 7px;
margin: 0;
padding: 0;
list-style: none;
}
.update-progress-step {
display: flex;
align-items: center;
gap: 9px;
color: var(--text-secondary);
font-size: 13px;
}
/* Кружок шага: пустой — впереди, с пульсом — идёт сейчас, с галкой — пройден. */
.update-progress-mark {
display: flex;
width: 16px;
height: 16px;
flex: none;
align-items: center;
justify-content: center;
border: 1.5px solid var(--n-6);
border-radius: 50%;
background: var(--surface-card);
color: #ffffff;
}
.update-progress-step.is-done {
color: var(--text-body);
}
.update-progress-step.is-done .update-progress-mark {
border-color: var(--success);
background: var(--success);
}
.update-progress-step.is-current {
color: var(--primary-text);
font-weight: 600;
}
.update-progress-step.is-current .update-progress-mark {
border-color: var(--primary);
box-shadow: 0 0 0 0 color-mix(in srgb, var(--accent) 45%, transparent);
animation: update-step-pulse 1.6s ease-out infinite;
}
.update-progress-step.is-current .update-progress-mark::after {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--primary);
content: "";
}
@keyframes update-step-pulse {
70% {
box-shadow: 0 0 0 5px transparent;
}
100% {
box-shadow: 0 0 0 0 transparent;
}
}
@media (prefers-reduced-motion: reduce) {
.update-progress-step.is-current .update-progress-mark {
animation: none;
}
}
.update-progress-hint {
margin: 0;
color: var(--text-secondary);
font-size: 12.5px;
}
/* Итог установки: версия встала, а страница всё ещё на прошлой. */
.update-done {
display: flex;
align-items: center;
gap: 11px;
margin-top: 14px;
padding: 12px 14px;
border: 1px solid var(--success-border);
border-radius: var(--radius-xl);
background: var(--success-bg);
color: var(--success-text);
}
.update-done > svg {
flex: none;
}
.update-done > div {
display: flex;
min-width: 0;
flex: 1;
flex-direction: column;
gap: 2px;
}
.update-done strong {
font-size: 13.5px;
font-weight: 600;
}
.update-done small {
color: var(--text-secondary);
font-size: 12.5px;
}
/* В полосе поверх рабочей области места на список шагов нет — то же знание
сворачивается в тонкую линию и подпись «шаг N из M». */
.update-banner-bar {
display: block;
overflow: hidden;
width: 132px;
height: 4px;
flex: none;
border-radius: var(--radius-pill);
background: color-mix(in srgb, currentColor 22%, transparent);
}
.update-banner-bar > span {
display: block;
height: 100%;
border-radius: inherit;
background: currentColor;
transition: width 0.4s ease;
}
.update-banner-step {
flex: none;
font-size: 12.5px;
font-variant-numeric: tabular-nums;
opacity: 0.8;
}
@@ -2,24 +2,71 @@ import { useCallback, useEffect, useRef, useState } from "react";
import { fetchUpdate, installInProgress, type UpdateInfo } from "./api";
// Состояние обновления с опросом, пока идёт установка. Во время перезапуска
// бэкенд не отвечает — это не ошибка, а стадия: запросы продолжают уходить,
// пока новая версия не ответит статусом DONE или FAILED.
// Состояние обновления с опросом, пока идёт установка.
//
// Обновление перезапускает тот самый бэкенд, который о нём и рассказывает, —
// поэтому «не отвечает» здесь не ошибка, а стадия. По той же причине признак
// «идёт установка» нельзя держать только в памяти вкладки: страница может
// перезагрузиться (руками или потому что человек ждёт) ровно тогда, когда
// сервисы лежат, и вернуться в интерфейс, где про обновление нет ни слова.
// Метка живёт в sessionStorage до подтверждения от поднявшейся версии.
const POLL_INTERVAL_MS = 3000;
const MARK_KEY = "chatballs.update.pending";
// Обновление занимает минуты; за час оно либо кончилось, либо не кончится уже
// никогда — забытая метка не должна вечно показывать мнимую установку.
const MARK_TTL_MS = 60 * 60 * 1000;
type Mark = { version: string | null; at: number };
function readMark(): Mark | null {
try {
const raw = sessionStorage.getItem(MARK_KEY);
if (!raw) return null;
const mark = JSON.parse(raw) as Mark;
if (typeof mark?.at !== "number" || Date.now() - mark.at > MARK_TTL_MS) {
sessionStorage.removeItem(MARK_KEY);
return null;
}
return mark;
} catch {
return null;
}
}
function writeMark(mark: Mark): void {
try {
sessionStorage.setItem(MARK_KEY, JSON.stringify(mark));
} catch {
/* приватное окно — переживём и без метки */
}
}
function clearMark(): void {
try {
sessionStorage.removeItem(MARK_KEY);
} catch {
/* см. выше */
}
}
export function useUpdateInfo(enabled: boolean) {
const [info, setInfo] = useState<UpdateInfo | null>(null);
const [unreachable, setUnreachable] = useState(false);
const wasInstalling = useRef(false);
// Метка переживает перезагрузку страницы, состояние — нет; нужны обе.
const [pending, setPending] = useState<Mark | null>(() => readMark());
// Версия, которую бэкенд назвал при загрузке этой страницы. Если она потом
// изменилась — в браузере лежит код прошлой версии, и его надо перечитать.
const loadedVersion = useRef<string | null>(null);
const load = useCallback(async () => {
try {
const next = await fetchUpdate();
if (loadedVersion.current === null) loadedVersion.current = next.currentVersion;
setInfo(next);
setUnreachable(false);
} catch {
// Нет прав, сеть моргнула или бэкенд перезапускается.
// Нет прав, сеть моргнула или бэкенд перезапускается — различить нельзя.
setUnreachable(true);
}
}, []);
@@ -28,8 +75,39 @@ export function useUpdateInfo(enabled: boolean) {
if (enabled) void load();
}, [enabled, load]);
const installing = installInProgress(info) || (unreachable && wasInstalling.current);
if (installInProgress(info)) wasInstalling.current = true;
const running = installInProgress(info);
// Пока бэкенд молчит, верим метке: молчит он как раз потому, что обновляется.
const installing = running || (pending !== null && (unreachable || info === null));
useEffect(() => {
if (running && info) {
// Начало берётся от сервера: после перезагрузки страницы бэкенд уже может
// не отвечать, и счётчик времени иначе пошёл бы заново.
const requested = Date.parse(info.install.requestedAt ?? "");
const mark = { version: info.install.version, at: Number.isNaN(requested) ? Date.now() : requested };
writeMark(mark);
setPending(mark);
}
}, [running, info]);
// Ответ поднявшегося бэкенда — единственное надёжное «всё кончилось».
const settled = pending !== null && !unreachable && info !== null && !running;
// Страница уже открыта в той версии, что назвал бэкенд, — значит код в
// браузере свежий и перечитывать его незачем.
const pageIsCurrent = loadedVersion.current !== null && info?.currentVersion === loadedVersion.current;
// Единственный итог, который надо держать на экране до действия человека:
// версия встала, а в браузере всё ещё код прошлой. Об остальном (сорвалось,
// страницу уже перезагрузили) рассказывает сам ответ, метка больше не нужна.
const needsReload = settled && info?.install.status === "DONE" && !pageIsCurrent;
const forget = useCallback(() => {
clearMark();
setPending(null);
}, []);
useEffect(() => {
if (settled && !needsReload) forget();
}, [settled, needsReload, forget]);
useEffect(() => {
if (!enabled || !installing) return undefined;
@@ -37,5 +115,28 @@ export function useUpdateInfo(enabled: boolean) {
return () => window.clearInterval(timer);
}, [enabled, installing, load]);
return { info, setInfo, reload: load, installing, unreachable, finished: wasInstalling.current && !installing };
return {
info,
setInfo,
reload: load,
installing,
unreachable,
/** Установка завершилась, а страница всё ещё на прошлой версии. */
finished: needsReload,
/** Убрать сообщение о результате: показано и больше не нужно. */
forget,
/**
* Пометить установку запущенной, не дожидаясь ответа сервера: прогресс
* обязан появиться в тот же миг, когда нажали кнопку.
*/
markPending: useCallback((version: string | null) => {
const mark = { version, at: Date.now() };
writeMark(mark);
setPending(mark);
}, []),
/** Версия, до которой идёт установка, даже когда бэкенд молчит. */
targetVersion: info?.install.version ?? pending?.version ?? null,
/** Момент запуска установки — от него считается прошедшее время. */
startedAt: info?.install.requestedAt ?? (pending ? new Date(pending.at).toISOString() : null),
};
}
+20 -5
View File
@@ -254,9 +254,11 @@ export const en: Record<MessageKey, Message> = {
"ai.cannot_delete_named": "“{name}” cannot be deleted: {reason}. Move its contents out first.",
"ai.categories": "CATEGORIES",
"ai.categories_do_not_nest_deeper": "Categories do not nest deeper than three levels",
"ai.collapse_category": "Collapse “{name}”",
"ai.category": "Category",
"ai.category_cannot_moved_inside_itself": "A category cannot be moved inside itself.",
"ai.category_empty_will_delete": "The category “{name}” is empty and will be deleted.",
"ai.expand_category": "Expand “{name}”",
"ai.category_file_matches_current_one": "the category in the file matches the current one",
"ai.category_has_no_items": "The category “{name}” has no knowledge yet",
"ai.category_named": "category “{name}”",
@@ -352,10 +354,10 @@ export const en: Record<MessageKey, Message> = {
"ai.how_agent_will_answer": "HOW THE AGENT WILL ANSWER",
"ai.how_agent_will_answer_2": "How the agent will answer",
"ai.how_should_speak": "How it should speak",
"ai.import_category_policy": "Missing categories from the file are created automatically. Without a path, new documents go to “No category” and existing documents keep their category.",
"ai.import_category_creation": "Missing categories will be created automatically.",
"ai.import_documents": "Import {count}",
"ai.import_finished": "Import finished",
"ai.import_never_creates_categories_implicitly": "Import never creates categories implicitly: a document with a non-existent",
"ai.import_path_tail": "is rejected on its own, the rest are applied. Without a path a document lands in “No category”.",
"ai.index_up_date": "The index is up to date",
"ai.indexing": "INDEXING",
"ai.insert_link_into_text": "Insert a link into the text",
@@ -381,7 +383,6 @@ export const en: Record<MessageKey, Message> = {
"ai.list": "List",
"ai.manage": "Manage",
"ai.material_import": "Material import",
"ai.missing_category_path": "there is no category “{name}” — import never creates categories. Create it or drop the last level of the path.",
"ai.move": "Move",
"ai.move_category": "Move to a category",
"ai.name_group_all_takes_start": "A name and a group are all it takes to start. Instructions, knowledge and connections are set later on the card.",
@@ -421,6 +422,7 @@ export const en: Record<MessageKey, Message> = {
"ai.pick_file": "Pick a file",
"ai.pick_provider": "pick a provider",
"ai.pick_them_from_disk": "pick them from disk",
"ai.other_knowledge": "Other knowledge",
"ai.portal_named": "Portal · {name}",
"ai.preview": "Preview",
"ai.preview_of": "PREVIEW · {what}",
@@ -438,6 +440,7 @@ export const en: Record<MessageKey, Message> = {
"ai.search_by_title": "Search by title",
"ai.search_by_title_description": "Search by title and description",
"ai.select": "Select",
"ai.select_all": "Select all",
"ai.select_every_knowledge_item_page": "Select every knowledge item on the page",
"ai.select_item": "Select “{title}”",
"ai.select_knowledge": "Select knowledge",
@@ -649,11 +652,11 @@ export const en: Record<MessageKey, Message> = {
"conversations.connecting_call": "Connecting the call",
"conversations.conversation": "Conversation",
"conversations.conversation_actions": "Conversation actions",
"conversations.conversation_and_history_gone_forever": "The conversation and the whole history will be deleted for good. The customer sees an empty chat and its notifications disappear.",
"conversations.conversation_closed": "The conversation is closed",
"conversations.conversation_context": "Conversation context",
"conversations.conversation_has_ended": "The conversation has ended.",
"conversations.conversation_list_width": "Conversation list width",
"conversations.conversation_moves_archive_leaves_lists": "The conversation moves to the archive and leaves the lists. Only administrators see the archive.",
"conversations.conversation_will_closed_replies_stay": "The conversation will be closed for replies and stay in history marked “Spam”.",
"conversations.conversation_without_group_visible_every": "A conversation without a group is visible to every operator.",
"conversations.conversations_appear_once_customers_write": "Conversations appear once customers write to your agent",
@@ -905,6 +908,7 @@ export const en: Record<MessageKey, Message> = {
"portals.invalid_yaml": "Invalid YAML",
"portals.ip_of_installation": "{value} · this installation's IP",
"portals.knowledge_base_support": "Knowledge base and support",
"portals.powered_by": "Powered by",
"portals.language": "Language",
"portals.language_2": "Language:",
"portals.language_3": "LANGUAGE",
@@ -1062,7 +1066,7 @@ export const en: Record<MessageKey, Message> = {
"onboarding.s1.name": "AI provider",
"onboarding.s1.time": "3 min",
"onboarding.s1.title": "Connect an AI provider",
"onboarding.s1.lead": "Without a provider the agent cannot answer anyone. The key is yours: spend, limits and tokens are all counted in the system.",
"onboarding.s1.lead": "Without a provider the agent cannot answer anyone. The key is yours: you pay the provider at your own rate.",
"onboarding.s1.path": "Settings → AI provider → Add provider",
"onboarding.s1.do_1": "Open Settings in the sidebar and pick the AI provider section.",
"onboarding.s1.do_2": "Click Add provider: OpenRouter, Custom (OpenAI-compatible) or a local model.",
@@ -1288,6 +1292,16 @@ export const en: Record<MessageKey, Message> = {
"updates.stage_downloading": "downloading the release file",
"updates.stage_pulling": "pulling images",
"updates.stage_restarting": "restarting services",
"updates.step_starting": "Starting the update",
"updates.step_downloading": "Downloading the release file",
"updates.step_pulling": "Pulling the images",
"updates.step_restarting": "Restarting the services",
"updates.progress_title": "Updating to {version}",
"updates.progress_step": "Step {step} of {total}",
"updates.progress_hint": "On the last step the application stops responding for a short while — that is expected. You can reload the page: the progress is not lost.",
"updates.done_title": "Version {version} is installed",
"updates.done_hint": "This page is still running the previous version. Reload it to use the new one.",
"updates.reload": "Reload the page",
"updates.done": "Version {version} is installed. Reload the page.",
"updates.failed": "Update to {version} failed",
"updates.confirm_title": "Update to {version}?",
@@ -1654,6 +1668,7 @@ export const en: Record<MessageKey, Message> = {
"shared.new_knowledge_item": "New knowledge item",
"shared.next_page": "Next page",
"shared.orange": "Orange",
"shared.per_page": "Per page:",
"shared.pick_agent": "Pick an agent",
"shared.portal_settings": "Portal settings",
"shared.previous_page": "Previous page",
+20 -5
View File
@@ -255,9 +255,11 @@ export const ru = {
"ai.cannot_delete_named": "«{name}» удалить нельзя: {reason}. Сначала перенесите содержимое.",
"ai.categories": "КАТЕГОРИИ",
"ai.categories_do_not_nest_deeper": "Глубже трёх уровней категории не вкладываются",
"ai.collapse_category": "Свернуть «{name}»",
"ai.category": "Категория",
"ai.category_cannot_moved_inside_itself": "Категорию нельзя перенести внутрь себя.",
"ai.category_empty_will_delete": "Категория «{name}» пуста и будет удалена.",
"ai.expand_category": "Развернуть «{name}»",
"ai.category_file_matches_current_one": "категория в файле совпадает с текущей",
"ai.category_has_no_items": "В категории «{name}» пока нет знаний",
"ai.category_named": "категория «{name}»",
@@ -353,10 +355,10 @@ export const ru = {
"ai.how_agent_will_answer": "КАК ОТВЕТИТ АГЕНТ",
"ai.how_agent_will_answer_2": "Как ответит агент",
"ai.how_should_speak": "Как он должен говорить",
"ai.import_category_policy": "Недостающие категории из файла создаются автоматически. Без указания пути новый документ попадает в «Без категории», существующий сохраняет свою категорию.",
"ai.import_category_creation": "Недостающие категории будут созданы автоматически.",
"ai.import_documents": "Импортировать {count}",
"ai.import_finished": "Импорт завершён",
"ai.import_never_creates_categories_implicitly": "Импорт не создаёт категории неявно: документ с несуществующим",
"ai.import_path_tail": "отклоняется отдельно, остальные применяются. Без указания пути документ попадает в «Без категории».",
"ai.index_up_date": "Индекс актуален",
"ai.indexing": "ИНДЕКСАЦИЯ",
"ai.insert_link_into_text": "Вставить ссылку в текст",
@@ -382,7 +384,6 @@ export const ru = {
"ai.list": "Список",
"ai.manage": "Управлять",
"ai.material_import": "Импорт материалов",
"ai.missing_category_path": "категории «{name}» нет — импорт не создаёт категории. Создайте её или уберите последний уровень пути.",
"ai.move": "Переместить",
"ai.move_category": "Переместить в категорию",
"ai.name_group_all_takes_start": "Имя и группа — всё, что нужно для старта. Инструкции, знания и подключения настраиваются потом на карточке.",
@@ -422,6 +423,7 @@ export const ru = {
"ai.pick_file": "Выбрать файл",
"ai.pick_provider": "выберите провайдера",
"ai.pick_them_from_disk": "выберите на диске",
"ai.other_knowledge": "Прочие знания",
"ai.portal_named": "Портал · {name}",
"ai.preview": "Просмотр",
"ai.preview_of": "ПРЕДПРОСМОТР · {what}",
@@ -439,6 +441,7 @@ export const ru = {
"ai.search_by_title": "Поиск по названию",
"ai.search_by_title_description": "Поиск по заголовку и описанию",
"ai.select": "Выбрать",
"ai.select_all": "Выбрать все",
"ai.select_every_knowledge_item_page": "Выбрать все знания на странице",
"ai.select_item": "Выбрать «{title}»",
"ai.select_knowledge": "Выбрать знания",
@@ -650,11 +653,11 @@ export const ru = {
"conversations.connecting_call": "Соединяем звонок",
"conversations.conversation": "Диалог",
"conversations.conversation_actions": "Действия диалога",
"conversations.conversation_and_history_gone_forever": "Диалог и вся переписка будут удалены безвозвратно. Клиент увидит пустой чат, уведомления по нему исчезнут.",
"conversations.conversation_closed": "Диалог закрыт",
"conversations.conversation_context": "Контекст диалога",
"conversations.conversation_has_ended": "Разговор завершён.",
"conversations.conversation_list_width": "Ширина списка диалогов",
"conversations.conversation_moves_archive_leaves_lists": "Диалог уйдёт в архив и исчезнет из списков. Архив видят только администраторы.",
"conversations.conversation_will_closed_replies_stay": "Диалог будет закрыт для ответов и останется в истории со статусом «Спам».",
"conversations.conversation_without_group_visible_every": "Диалог без группы видят все сотрудники.",
"conversations.conversations_appear_once_customers_write": "Диалоги появятся, когда клиенты напишут вашему агенту",
@@ -906,6 +909,7 @@ export const ru = {
"portals.invalid_yaml": "Невалидный YAML",
"portals.ip_of_installation": "{value} · IP этой установки",
"portals.knowledge_base_support": "База знаний и поддержка",
"portals.powered_by": "Работает на",
"portals.language": "Язык",
"portals.language_2": "Язык:",
"portals.language_3": "ЯЗЫК",
@@ -1063,7 +1067,7 @@ export const ru = {
"onboarding.s1.name": "AI-провайдер",
"onboarding.s1.time": "3 мин",
"onboarding.s1.title": "Подключите AI-провайдера",
"onboarding.s1.lead": "Без провайдера агент не ответит ни одному клиенту. Ключ ваш: расходы, лимиты и токены считаются в системе.",
"onboarding.s1.lead": "Без провайдера агент не ответит ни одному клиенту. Ключ ваш: расходы идут по вашему тарифу у провайдера.",
"onboarding.s1.path": "Настройки → AI-провайдер → Добавить провайдера",
"onboarding.s1.do_1": "В сайдбаре откройте «Настройки» и выберите раздел «AI-провайдер».",
"onboarding.s1.do_2": "Нажмите «Добавить провайдера»: OpenRouter, Custom (OpenAI-compatible) или локальная модель.",
@@ -1289,6 +1293,16 @@ export const ru = {
"updates.stage_downloading": "загрузка файла релиза",
"updates.stage_pulling": "загрузка образов",
"updates.stage_restarting": "перезапуск сервисов",
"updates.step_starting": "Запуск обновления",
"updates.step_downloading": "Загрузка файла релиза",
"updates.step_pulling": "Загрузка образов",
"updates.step_restarting": "Перезапуск сервисов",
"updates.progress_title": "Обновление до {version}",
"updates.progress_step": "Шаг {step} из {total}",
"updates.progress_hint": "На последнем шаге приложение ненадолго перестанет отвечать — так и должно быть. Страницу можно перезагрузить: ход обновления не потеряется.",
"updates.done_title": "Установлена версия {version}",
"updates.done_hint": "Страница открыта в прошлой версии. Обновите её, чтобы работать в новой.",
"updates.reload": "Обновить страницу",
"updates.done": "Установлена версия {version}. Обновите страницу.",
"updates.failed": "Обновление до {version} не удалось",
"updates.confirm_title": "Обновить до {version}?",
@@ -1655,6 +1669,7 @@ export const ru = {
"shared.new_knowledge_item": "Новое знание",
"shared.next_page": "Следующая страница",
"shared.orange": "Оранжевый",
"shared.per_page": "На странице:",
"shared.pick_agent": "Выберите агента",
"shared.portal_settings": "Настройки портала",
"shared.previous_page": "Предыдущая страница",
+1 -2
View File
@@ -266,8 +266,7 @@
.hub-brand-mark.has-logo {
overflow: hidden;
color: inherit;
background: var(--surface-card);
border: 1px solid var(--n-7);
background: transparent;
}
.hub-brand-mark img {
+2 -2
View File
@@ -29,10 +29,10 @@ function SurfaceApp() {
}, [surface]);
if (surface === "loading") {
return <main className="help-boot-loading"><Loader size={44} /></main>;
return <main className="help-boot-loading"><Loader size={72} /></main>;
}
return surface === "help"
? <Suspense fallback={<main className="help-boot-loading"><Loader size={44} /></main>}><HelpCenterApp /></Suspense>
? <Suspense fallback={<main className="help-boot-loading"><Loader size={72} /></main>}><HelpCenterApp /></Suspense>
: <App />;
}
@@ -0,0 +1,36 @@
import { useState } from "react";
import { Icon } from "./icons";
import { SelectMenu } from "./ui-controls";
import { PAGE_SIZE_OPTIONS } from "./usePageSize";
import { t } from "../i18n";
// «На странице: 20» в подвале списка. Отдельный селект приложения заводить не
// надо: это тот же `SelectMenu`, только с компактным триггером подвала.
export function PageSizeSelect({ value, onChange, options = PAGE_SIZE_OPTIONS }: {
value: number;
onChange: (pageSize: number) => void;
options?: number[];
}) {
const [open, setOpen] = useState(false);
return (
<SelectMenu
open={open}
options={options.map((size) => ({ value: String(size), label: String(size) }))}
overlayClassName="app-dropdown is-pager-size"
selected={[String(value)]}
onOpenChange={setOpen}
onSelect={(next) => {
setOpen(false);
onChange(Number(next));
}}
>
<button className="pager-size" type="button">
<i>{t("shared.per_page")}</i>
{value}
<Icon name="chevron" size={12} strokeWidth={2.2} />
</button>
</SelectMenu>
);
}
+10 -1
View File
@@ -1,6 +1,7 @@
import type { ReactNode } from "react";
import { Icon } from "./icons";
import { PageSizeSelect } from "./PageSizeSelect";
import { paginationItems } from "./ui-controls";
import "./pagination.css";
import { t } from "../i18n";
@@ -15,6 +16,8 @@ export function Pagination({
onPage,
note,
className = "",
pageSize,
onPageSize,
}: {
page: number;
pageCount: number;
@@ -22,10 +25,16 @@ export function Pagination({
/** Подпись слева: «Показано 20 из 45», подсказка про архив и т.п. */
note?: ReactNode;
className?: string;
/** Размер страницы; вместе с `onPageSize` включает выбор «На странице». */
pageSize?: number;
onPageSize?: (pageSize: number) => void;
}) {
return (
<div className={`pager ${className}`.trim()}>
<span className="pager-note">{note}</span>
<span className="pager-note">
{note}
{pageSize !== undefined && onPageSize && <PageSizeSelect value={pageSize} onChange={onPageSize} />}
</span>
<span className="pager-pages">
<button
aria-label={t("shared.previous_page")}
+16
View File
@@ -0,0 +1,16 @@
import type { PagedPayload } from "./usePagedResource";
// Весь набор постранично. Нужен там, где интерфейс работает не со страницей, а
// с полным списком: выбор знаний агента, выгрузка. Обычные списки так грузить
// нельзя — это ровно то, от чего уходили, когда вводили страницы.
export async function allPages<T>(load: (page: number) => Promise<PagedPayload<T>>): Promise<T[]> {
const items: T[] = [];
let page = 1;
for (;;) {
const payload = await load(page);
items.push(...payload.items);
if (payload.items.length === 0 || payload.page >= payload.pageCount) return items;
page = payload.page + 1;
}
}
@@ -69,3 +69,36 @@
font-weight: 600;
cursor: default;
}
/* «На странице: 20» — выбор размера страницы рядом с подписью подвала. */
.pager-size {
display: inline-flex;
height: 28px;
align-items: center;
gap: 6px;
padding: 0 8px;
border: 1px solid var(--n-7);
border-radius: 7px;
background: var(--surface-card);
color: var(--n-3);
font-family: inherit;
font-size: 12.5px;
cursor: pointer;
}
.pager-size:hover {
background: var(--n-9);
}
.pager-size i {
color: var(--n-5);
font-style: normal;
}
.pager-size > svg:last-child {
color: var(--n-5);
}
.app-dropdown.is-pager-size .ant-dropdown-menu {
width: 108px;
}
+14
View File
@@ -15,3 +15,17 @@
color: var(--n-3);
font-size: 13px;
}
/* Загрузчик портала до прихода манифеста и темы. Живёт в общем CSS, а не в
чанке портала: чанк грузится лениво, и первый кадр иначе рисуется без
стилей. Фиксированный слой на весь экран — центр не зависит от отступов
и высоты документа. */
.help-boot-loading {
position: fixed;
inset: 0;
display: grid;
place-items: center;
margin: 0;
background: var(--help-bg, var(--surface-page));
color: var(--help-ink, var(--text-body));
}
+1 -1
View File
@@ -108,7 +108,7 @@ export function LoadingState({ variant = "page" }: { variant?: "page" | "inline"
}
export function LoadingScreen() {
return <main className="state-screen"><div className="state-card"><LoadingState variant="inline" /></div></main>;
return <main className="state-screen"><LogoSpinner size={56} /></main>;
}
export function ErrorScreen({ retry }: { retry: () => void }) {
@@ -0,0 +1,94 @@
import { useCallback, useEffect, useRef, useState, type CSSProperties, type PointerEvent as ReactPointerEvent } from "react";
// Плавающий элемент, который человек перетаскивает мышью: положение считается
// от правого нижнего угла окна и живёт в localStorage этого браузера.
//
// Отступ от края — от правого и нижнего, а не от левого и верхнего: элемент
// прижат к этому углу и при изменении размера окна должен оставаться там же,
// а не уезжать за границу вместе с координатой.
export type EdgeOffset = { right: number; bottom: number };
const STORAGE_PREFIX = "chatballs.ui.position.";
// Ближе к краю элемент не подводим: кнопка, прижатая вплотную, читается как
// обрезанная, а на краю окна её ещё и трудно поймать курсором.
const EDGE_GAP = 8;
// Сдвиг меньше этого — не перетаскивание, а дрожание руки при клике.
const DRAG_THRESHOLD = 3;
function clamp(value: number, max: number): number {
return Math.min(Math.max(value, EDGE_GAP), Math.max(EDGE_GAP, max));
}
function readStored(key: string, fallback: EdgeOffset): EdgeOffset {
try {
const raw = window.localStorage.getItem(STORAGE_PREFIX + key);
if (!raw) return fallback;
const stored = JSON.parse(raw) as Partial<EdgeOffset>;
return Number.isFinite(stored.right) && Number.isFinite(stored.bottom)
? { right: Number(stored.right), bottom: Number(stored.bottom) }
: fallback;
} catch {
return fallback;
}
}
export function useDraggablePosition<T extends HTMLElement>(key: string, fallback: EdgeOffset) {
const [offset, setOffset] = useState<EdgeOffset>(() => readStored(key, fallback));
const [dragging, setDragging] = useState(false);
const ref = useRef<T | null>(null);
// Клик по элементу приходит и после перетаскивания: без этой отметки
// отпущенная кнопка тут же срабатывала бы как нажатая.
const moved = useRef(false);
useEffect(() => {
try {
window.localStorage.setItem(STORAGE_PREFIX + key, JSON.stringify(offset));
} catch {
/* без сохранения — положение держится до перезагрузки */
}
}, [key, offset]);
const fit = useCallback((next: EdgeOffset): EdgeOffset => {
const box = ref.current?.getBoundingClientRect();
const width = box?.width ?? 0;
const height = box?.height ?? 0;
return {
right: clamp(next.right, window.innerWidth - width - EDGE_GAP),
bottom: clamp(next.bottom, window.innerHeight - height - EDGE_GAP),
};
}, []);
// Окно уменьшили — элемент возвращается в видимую область, иначе он остался
// бы за краем и человек больше не смог бы до него дотянуться.
useEffect(() => {
const onResize = () => setOffset((current) => fit(current));
window.addEventListener("resize", onResize);
return () => window.removeEventListener("resize", onResize);
}, [fit]);
const onPointerDown = useCallback((event: ReactPointerEvent<HTMLElement>) => {
if (event.button !== 0) return;
const start = { x: event.clientX, y: event.clientY, ...offset };
moved.current = false;
setDragging(true);
const move = (moveEvent: PointerEvent) => {
const shiftX = start.x - moveEvent.clientX;
const shiftY = start.y - moveEvent.clientY;
if (Math.abs(shiftX) > DRAG_THRESHOLD || Math.abs(shiftY) > DRAG_THRESHOLD) moved.current = true;
setOffset(fit({ right: start.right + shiftX, bottom: start.bottom + shiftY }));
};
const up = () => {
setDragging(false);
window.removeEventListener("pointermove", move);
window.removeEventListener("pointerup", up);
document.body.style.userSelect = "";
};
document.body.style.userSelect = "none";
window.addEventListener("pointermove", move);
window.addEventListener("pointerup", up);
}, [fit, offset]);
const style: CSSProperties = { right: offset.right, bottom: offset.bottom };
return { dragging, onPointerDown, ref, style, wasDragged: () => moved.current };
}
@@ -0,0 +1,36 @@
import { useCallback, useState } from "react";
// Сколько записей показывать на странице. Выбор принадлежит человеку и его
// браузеру: у одного список на ноутбуке, у другого — на широком мониторе, и
// навязывать обоим одно число незачем. Верхняя граница — потолок сервера
// (`api/pagination.py`, MAX_PAGE_SIZE).
export const PAGE_SIZE_OPTIONS = [20, 50, 100];
export const DEFAULT_PAGE_SIZE = 20;
const STORAGE_PREFIX = "chatballs.ui.pageSize.";
function readStored(key: string, fallback: number): number {
try {
const raw = window.localStorage.getItem(STORAGE_PREFIX + key);
const value = raw ? Number(raw) : NaN;
return PAGE_SIZE_OPTIONS.includes(value) ? value : fallback;
} catch {
return fallback;
}
}
export function usePageSize(key: string, fallback = DEFAULT_PAGE_SIZE) {
const [pageSize, setStored] = useState(() => readStored(key, fallback));
const setPageSize = useCallback((next: number) => {
setStored(next);
try {
window.localStorage.setItem(STORAGE_PREFIX + key, String(next));
} catch {
/* без сохранения — выбор держится до перезагрузки */
}
}, [key]);
return { pageSize, setPageSize };
}
+1
View File
@@ -28,4 +28,5 @@
@import "./features/settings/styles.css";
@import "./features/onboarding/styles.css";
@import "./features/notifications/styles.css";
@import "./features/updates/styles.css";
@import "./shared/state.css";
+9
View File
@@ -78,6 +78,15 @@ export function App() {
}, []);
function ingestPoll(data: Poll, notify = true) {
// Диалог удалили на стороне поддержки — переписка обнуляется, и виджет
// продолжает жить как только что открытый.
if (data.reset) {
lastId.current = 0;
setMessages([]);
setPending([]);
setAwaiting(false);
setContactSent(false);
}
setState(data.state);
setCall(data.call?.callId === openedCallId.current ? null : (data.call ?? null));
if (!data.messages.length) return;
+5 -4
View File
@@ -1,6 +1,7 @@
import { useEffect, useRef, useState, type RefObject } from "react";
import { isVideoCall, type CallInfo, type WebConfig, type WebMessage } from "./api";
import { VoiceMessage } from "./VoiceMessage";
import type { useVoiceRecorder } from "./useVoiceRecorder";
import { fmt, t } from "./i18n";
@@ -56,7 +57,7 @@ export function ChatBody({ bodyRef, config, unavailable, accepted, accent, lette
{config.greeting && <Bubble author="ai" text={config.greeting} accent={accent} />}
{messages.map((message) => message.author === "system"
? <SystemMessage key={message.id} text={message.text} />
: <div key={message.id}><Bubble author={message.author} text={message.hasAudio ? "" : message.text || (message.kind === "voice" ? t("chat.voice_message") : "")} accent={accent} time={message.createdAt} audioUrl={message.hasAudio && audioUrlFor ? audioUrlFor(message.id) : undefined} attachment={message.kind === "file" && message.attachment ? { ...message.attachment, url: message.attachment.available && attachmentUrlFor ? attachmentUrlFor(message.id, false) : "", inlineUrl: message.attachment.available && attachmentUrlFor ? attachmentUrlFor(message.id, true) : "" } : undefined} />{message.kind === "contact_request" && message.id === lastContactRequestId && showPhoneForm && <PhoneForm accent={accent} onSubmit={onSubmitContact} />}</div>)}
: <div key={message.id}><Bubble author={message.author} text={message.hasAudio ? "" : message.text || (message.kind === "voice" ? t("chat.voice_message") : "")} accent={accent} time={message.createdAt} audioUrl={message.hasAudio && audioUrlFor ? audioUrlFor(message.id) : undefined} voice={{ messageId: message.id, durationSeconds: message.durationSeconds }} attachment={message.kind === "file" && message.attachment ? { ...message.attachment, url: message.attachment.available && attachmentUrlFor ? attachmentUrlFor(message.id, false) : "", inlineUrl: message.attachment.available && attachmentUrlFor ? attachmentUrlFor(message.id, true) : "" } : undefined} />{message.kind === "contact_request" && message.id === lastContactRequestId && showPhoneForm && <PhoneForm accent={accent} onSubmit={onSubmitContact} />}</div>)}
{pending.map((text, index) => <Bubble key={`p${index}`} author="client" text={text} accent={accent} pendingState />)}
{awaiting && <Typing />}
</>
@@ -202,10 +203,10 @@ function AttachmentContent({ attachment, text, light }: { attachment: BubbleAtta
);
}
export function Bubble({ author, text, accent, time, pendingState, audioUrl, attachment }: { author: "client" | "ai" | "operator"; text: string; accent: string; time?: string; pendingState?: boolean; audioUrl?: string; attachment?: BubbleAttachment }) {
export function Bubble({ author, text, accent, time, pendingState, audioUrl, voice, attachment }: { author: "client" | "ai" | "operator"; text: string; accent: string; time?: string; pendingState?: boolean; audioUrl?: string; voice?: { messageId: number; durationSeconds?: number }; attachment?: BubbleAttachment }) {
const at = formatTime(time);
const content = audioUrl
? <audio controls preload="none" src={audioUrl} style={{ width: 216, height: 36, display: "block" }} />
const content = audioUrl && voice
? <VoiceMessage accent={accent} durationSeconds={voice.durationSeconds} light={author === "client"} messageId={voice.messageId} url={audioUrl} />
: attachment
? <AttachmentContent attachment={attachment} text={text} light={author === "client"} />
: text;
+117
View File
@@ -0,0 +1,117 @@
import { useEffect, useRef, useState } from "react";
import { voiceWaveHeights } from "@chatballs/ui";
import { t } from "./i18n";
// Голосовое сообщение в виджете: тот же плеер, что видит оператор в своём чате
// (дизайн-базлайн v2, кадр H) — кнопка воспроизведения, волна и длительность.
// Расшифровки здесь нет: это инструмент оператора, клиент слушает свою же
// запись и в пересказе не нуждается.
function formatSeconds(total: number): string {
const seconds = Math.max(0, Math.round(total));
return `${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, "0")}`;
}
const PLAY_ICON = <svg viewBox="0 0 24 24" width="13" height="13" fill="currentColor" stroke="none"><polygon points="7 4 20 12 7 20" /></svg>;
const PAUSE_ICON = <svg viewBox="0 0 24 24" width="13" height="13" fill="currentColor" stroke="none"><rect x="7" y="4" width="3.6" height="16" rx="1" /><rect x="13.4" y="4" width="3.6" height="16" rx="1" /></svg>;
export function VoiceMessage({ messageId, url, durationSeconds, accent, light }: {
messageId: number;
url: string;
durationSeconds?: number;
accent: string;
/** Пузырь клиента — на акцентной заливке; плеер на ней рисуется белым. */
light: boolean;
}) {
const audioRef = useRef<HTMLAudioElement | null>(null);
const [playing, setPlaying] = useState(false);
const [progress, setProgress] = useState(0);
const [failed, setFailed] = useState(false);
const bars = voiceWaveHeights(messageId);
const played = Math.round(progress * bars.length);
useEffect(() => () => {
audioRef.current?.pause();
}, []);
function toggle() {
if (!audioRef.current) {
const audio = new Audio(url);
audio.addEventListener("timeupdate", () => {
if (audio.duration) setProgress(audio.currentTime / audio.duration);
});
audio.addEventListener("ended", () => {
setPlaying(false);
setProgress(0);
});
audioRef.current = audio;
}
if (playing) {
audioRef.current.pause();
setPlaying(false);
return;
}
void audioRef.current.play().catch(() => {
setFailed(true);
setPlaying(false);
});
setPlaying(true);
}
const idle = light ? "rgba(255,255,255,0.45)" : "#d9d9d9";
const done = light ? "#ffffff" : accent;
return (
<div style={{ minWidth: 216 }}>
<div style={{ display: "flex", alignItems: "center", gap: 10 }}>
<button
onClick={toggle}
aria-label={playing ? t("chat.pause") : t("chat.play")}
style={{
width: 34,
height: 34,
flex: "none",
border: "none",
borderRadius: "50%",
background: light ? "rgba(255,255,255,0.22)" : accent,
color: "#fff",
display: "flex",
alignItems: "center",
justifyContent: "center",
cursor: "pointer",
}}
>
{playing ? PAUSE_ICON : PLAY_ICON}
</button>
<span style={{ flex: 1, display: "flex", alignItems: "center", gap: 2, height: 24, minWidth: 110 }}>
{bars.map((height, index) => (
<i
key={index}
style={{
display: "block",
width: 3,
height,
flex: "none",
borderRadius: 1.5,
background: index < played ? done : idle,
}}
/>
))}
</span>
<span style={{
flex: "none",
fontSize: 12,
fontWeight: 500,
fontVariantNumeric: "tabular-nums",
color: light ? "rgba(255,255,255,0.8)" : "#8c8c8c",
}}>{formatSeconds(durationSeconds ?? 0)}</span>
</div>
{failed && (
<div style={{ marginTop: 6, fontSize: 11.5, color: light ? "rgba(255,255,255,0.9)" : "#cf1322" }}>
{t("chat.could_not_play")}
</div>
)}
</div>
);
}
+3 -1
View File
@@ -46,7 +46,9 @@ export const isVideoCall = (call: CallInfo | null | undefined): boolean => callK
export type CallBootstrap = { call: CallInfo; accessToken: string; iceServers: RTCIceServer[] };
export type CallStateEnvelope = { call: CallInfo; iceServers: RTCIceServer[] };
export type Poll = { state: "ai" | "operator" | "waiting"; lifecycle: string; messages: WebMessage[]; call?: CallInfo | null };
// reset — диалог, который показывал виджет, удалили в рабочем месте: ленту
// надо начать с чистого листа, иначе клиент видит переписку, которой нет.
export type Poll = { state: "ai" | "operator" | "waiting"; lifecycle: string; messages: WebMessage[]; call?: CallInfo | null; reset?: boolean };
export type WidgetEntry = { widgetKey?: string; channel?: string };
+3
View File
@@ -23,6 +23,9 @@ export const en: Record<ChatMessageKey, Message> = {
"chat.message_placeholder": "Write a message…",
"chat.operator_answering": "A specialist is answering",
"chat.passing_to_operator": "Passing to an operator",
"chat.pause": "Pause",
"chat.play": "Play",
"chat.could_not_play": "Could not play the recording",
"chat.record_voice": "Record a voice message",
"chat.recording": "Recording",
"chat.remove_file": "Remove the file",
+3
View File
@@ -24,6 +24,9 @@ export const ru = {
"chat.message_placeholder": "Напишите сообщение…",
"chat.operator_answering": "Отвечает специалист",
"chat.passing_to_operator": "Передаём оператору",
"chat.pause": "Пауза",
"chat.play": "Воспроизвести",
"chat.could_not_play": "Не удалось воспроизвести запись",
"chat.record_voice": "Записать голосовое",
"chat.recording": "Идёт запись",
"chat.remove_file": "Убрать файл",
+28 -29
View File
@@ -279,13 +279,21 @@ services:
backend-platform:
condition: service_healthy
# Coturn — медиа-relay для TURN fallback (SPEC-CHATBALLS-0013 §11).
# Опциональный profile `calls` (ADR-CHATBALLS-0028): включается через COMPOSE_PROFILES=calls.
# Host networking: relay использует широкий UDP-диапазон и реальный внешний IP,
# публикуется напрямую и не проходит через HTTP reverse proxy. credentials
# выдаёт backend по общему CHATBALLS_CALL_TURN_SECRET (static-auth-secret).
# Coturn — медиа-relay для звонков (SPEC-CHATBALLS-0013 §11).
#
# Поднимается вместе со всей установкой и слушает тот же адрес, что и веб:
# звонок через симметричный NAT или VPN без relay не соединяется в принципе,
# и превращать это в отдельную настройку значит выдавать клиенту заведомо
# неработающие звонки. Порт 3478 не спорит с 80 и 443, так что второй IP не
# нужен; TURN-over-TLS по умолчанию выключен — он требует сертификата и
# 443-го порта, то есть как раз выделенного адреса. Кому нужен TLS (сети,
# где наружу разрешён только 443), включает его отдельным compose-override.
#
# Host networking: relay использует широкий UDP-диапазон и реальный внешний
# IP, публикуется напрямую и не проходит через HTTP reverse proxy.
# credentials выдаёт backend по общему CHATBALLS_CALL_TURN_SECRET
# (static-auth-secret); адреса relay backend строит от адреса установки.
coturn:
profiles: ["calls"]
image: ${CHATBALLS_COTURN_IMAGE:-coturn/coturn:4.6}
restart: unless-stopped
network_mode: host
@@ -299,40 +307,31 @@ services:
# старт, человек его не вводит и не дублирует в двух местах.
- -c
- /run/chatballs/secrets/turnserver-secret.conf
- --realm=${CHATBALLS_CALL_TURN_REALM:-}
# Весь TURN живёт на выделенном публичном IP (отдельный порт/NIC), Caddy — на
# основном IP. Это освобождает 443 под TURN-over-TLS без конфликта с web.
- --listening-ip=${CHATBALLS_TURN_LISTENING_IP:-0.0.0.0}
- --relay-ip=${CHATBALLS_TURN_EXTERNAL_IP:-}
- --external-ip=${CHATBALLS_TURN_EXTERNAL_IP:-}
- --listening-port=${CHATBALLS_TURN_LISTENING_PORT:-3478}
# TURN-over-TLS на 443: проходит через VPN/строгие сети, где UDP и 3478 режут.
- --tls-listening-port=${CHATBALLS_TURN_TLS_PORT:-443}
- --cert=/etc/coturn/certs/fullchain.pem
- --pkey=/etc/coturn/certs/privkey.pem
- --min-port=${CHATBALLS_TURN_MIN_PORT:-49160}
# realm участвует только в digest-аутентификации; клиент узнаёт его от
# самого сервера, поэтому значение фиксированное.
- --realm=chatballs
- --listening-port=3478
# TLS и DTLS выключены: без сертификата они всё равно не поднялись бы, а
# сертификат означает 443 и выделенный адрес.
- --no-tls
- --no-dtls
- --min-port=49160
# Relay-диапазон UDP (1 порт на media-endpoint; ~2 порта на звонок).
# 49160-49999 = 840 портов ≈ 420 одновременных TURN-звонков на хосте.
# Расширяется через CHATBALLS_TURN_MIN_PORT/CHATBALLS_TURN_MAX_PORT; все порты диапазона
# должны быть открыты на firewall и не пересекаться с ephemeral-диапазоном ОС.
- --max-port=${CHATBALLS_TURN_MAX_PORT:-49999}
# Все порты диапазона должны быть открыты на firewall и не пересекаться
# с ephemeral-диапазоном ОС.
- --max-port=49999
# Запрет анонимного и внутрисетевого relay (SPEC §11: без пересечения с хостом).
- --no-multicast-peers
- --no-tcp-relay
- --denied-peer-ip=10.0.0.0-10.255.255.255
- --denied-peer-ip=172.16.0.0-172.31.255.255
- --denied-peer-ip=192.168.0.0-192.168.255.255
- --no-tlsv1
- --no-tlsv1_1
volumes:
# LE-сертификат TURN-хоста (turns:) кладёт на хост renewal deploy-hook —
# это единственное место, где стек смотрит наружу файлом, и только при
# включённом профиле calls. Каталог задаётся явно.
- chatballs-secrets:/run/chatballs/secrets:ro
- ${CHATBALLS_TURN_CERTS_DIR:-./data/coturn-certs}:/etc/coturn/certs:ro
healthcheck:
# Allocation smoke: STUN binding к собственному listener на выделенном IP.
test: ["CMD", "turnutils_stunclient", "-p", "${CHATBALLS_TURN_LISTENING_PORT:-3478}", "${CHATBALLS_TURN_LISTENING_IP:-0.0.0.0}"]
# Allocation smoke: STUN binding к собственному listener.
test: ["CMD", "turnutils_stunclient", "-p", "3478", "127.0.0.1"]
interval: 30s
timeout: 5s
retries: 5
-21
View File
@@ -97,27 +97,6 @@ verify_release_checksums() {
}
}
validate_calls_network_boundary() {
# Профиль calls включают переменной окружения — тем же способом, каким её
# читает сам compose. Файла с конфигурацией у установки нет.
local web_ip turn_ip
web_ip="${CHATBALLS_WEB_LISTENING_IP:-}"
turn_ip="${CHATBALLS_TURN_LISTENING_IP:-}"
[[ -n "$web_ip" ]] || {
log_err "CHATBALLS_WEB_LISTENING_IP is required for calls profile"
return 1
}
[[ "$web_ip" != "0.0.0.0" ]] || {
log_err "CHATBALLS_WEB_LISTENING_IP cannot be 0.0.0.0 when calls profile uses TURN TLS on 443"
return 1
}
[[ "$web_ip" != "$turn_ip" ]] || {
log_err "web and TURN listeners must use different public IP addresses"
return 1
}
}
release_image_keys() {
printf '%s\n' \
CHATBALLS_BACKEND_IMAGE \
+3 -11
View File
@@ -24,10 +24,7 @@ cmd_deploy() {
run_compose run --rm init || die "deploy: init (migrate) failed" 1
log "deploy: starting application services"
local app_services=(backend-app backend-platform backend-admin worker frontend gateway)
if profile_enabled calls; then
app_services+=(coturn)
fi
local app_services=(backend-app backend-platform backend-admin worker frontend gateway coturn)
run_compose up -d "${app_services[@]}" || die "deploy: application start failed" 1
log "deploy: waiting for application health"
@@ -36,9 +33,8 @@ cmd_deploy() {
_wait_running backend-admin 30 || die "deploy: admin backend did not start" 1
_wait_running frontend 30 || die "deploy: frontend did not start" 1
_wait_running gateway 30 || die "deploy: gateway did not start" 1
if profile_enabled calls; then
_wait_healthy coturn 60 || die "deploy: coturn did not become healthy" 1
fi
# Relay поднимается вместе со всеми: звонки за NAT без него не соединяются.
_wait_healthy coturn 60 || die "deploy: coturn did not become healthy" 1
log "deploy: running smoke checks"
_smoke || die "deploy: smoke checks failed" 1
@@ -57,10 +53,6 @@ _deploy_validate() {
# Домены здесь не проверяются: установка отвечает по адресу сервера, а свой
# домен владелец задаёт в «Настройках» — снаружи его знать неоткуда.
if profile_enabled calls; then
validate_calls_network_boundary || return 1
fi
compose_config_validate >/dev/null 2>&1 || {
log_err "compose config invalid"
return 1
Loaded 100 of 108 files, more files were not shown because too many files have changed in this diff. Show more