Compare commits

...
21 Commits
Author SHA1 Message Date
Andrey 089dd6ddc2 🔖 release: 1.10.0 2026-09-15 08:45:54 +03:00
AndreyandClaude Opus 5 ae46508d22 ✨ feat(ai): у агента своя модель для текста и своя для расшифровки
Ключ провайдера один на организацию, а агентов на нём несколько, и модель им нужна разная. Раньше модель принадлежала интеграции и копировалась на агента при каждом сохранении — выбрать её агенту было нельзя. Теперь на карточке агента две пары «провайдер + модель»: чем отвечать и чем расшифровывать голосовые. Пустое поле модели означает «как в интеграции», и подсказка в нём показывает её значение, поэтому агент, которому модель не выбирали, по-прежнему следует за настройкой ключа.

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 07:33:11 +03:00
Andrey 290b4ccea9 🔖 release: 1.8.1 2026-09-15 07:14:10 +03:00
AndreyandClaude Opus 5 ca1b892e11 🐛 fix(calls): отбой клиента закрывает окно звонка у оператора
Клиент кладёт трубку, сервер переводит звонок в терминал, а peer connection у оператора узнаёт об этом позже — или не узнаёт вовсе. Проверка терминального статуса в аудиозвонке стояла последней, после состояния соединения, поэтому у оператора оставался экран активного разговора с кнопкой «Завершить», которой нечего было завершать: она молча выходила по той же проверке. Теперь терминальный статус решает первым (как в видеозвонке), а «Завершить» на уже завершённом звонке просто закрывает окно.

Проверено: четыре теста разбора фазы аудиозвонка.

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 06:56:38 +03:00
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
110 changed files with 3315 additions and 698 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.2
1.10.0
+34
View File
@@ -108,6 +108,13 @@ def knowledge_total_for_organization(organization_id: int) -> int:
)
def _integration_model(integration, key: str) -> str:
"""Модель, заданная в интеграции: подсказка в поле модели на карточке."""
if integration is None:
return ""
return str((integration.config or {}).get(key) or "")
def agent_card_payload(channel: Channel, *, knowledge_total: int | None = None) -> dict[str, object]:
agent: AIAgent = channel.ai_agent
connections = _connections_payload(channel)
@@ -125,8 +132,18 @@ def agent_card_payload(channel: Channel, *, knowledge_total: int | None = None)
# Цвет группы задаётся в настройках — точка у названия (кадры G1/G3).
"groupColor": channel.group.color if channel.group_id else "",
"aiStatus": agent.status,
# Модели агента: пустая строка означает «как в интеграции», и тогда
# карточка показывает модель интеграции подсказкой в поле.
"model": agent.model,
"transcriptionModel": agent.transcription_model,
"providerModel": _integration_model(agent.provider_integration, "default_model"),
"transcriptionProviderModel": _integration_model(
agent.transcription_integration or agent.provider_integration,
"transcription_model",
),
"providerIntegrationId": agent.provider_integration_id,
# Чем расшифровывать голосовые; пусто — тем же провайдером, что отвечает.
"transcriptionIntegrationId": agent.transcription_integration_id,
"modelParams": agent.model_params,
"answerLanguage": agent.answer_language,
"persona": agent.persona,
@@ -230,6 +247,9 @@ def update_agent_card(
ai_fields = {
"providerIntegrationId",
"transcriptionIntegrationId",
"model",
"transcriptionModel",
"modelParams",
"persona",
"tone",
@@ -256,6 +276,15 @@ def update_agent_card(
provider_integration_id, int
):
raise ValidationError({"providerIntegrationId": t("api.integer_id_required")})
transcription_integration_id = body.get(
"transcriptionIntegrationId", agent.transcription_integration_id
)
if transcription_integration_id is not None and not isinstance(
transcription_integration_id, int
):
raise ValidationError(
{"transcriptionIntegrationId": t("api.integer_id_required")}
)
update_agent(
context=context,
agent=agent,
@@ -263,6 +292,11 @@ def update_agent_card(
# Имя агента следует за именем карточки: сущность одна.
name=channel.name,
provider_integration_id=provider_integration_id,
transcription_integration_id=transcription_integration_id,
model=str(body.get("model", agent.model)),
transcription_model=str(
body.get("transcriptionModel", agent.transcription_model)
),
model_params=model_params,
allowed_tools=agent.allowed_tools,
persona=str(body.get("persona", agent.persona)),
@@ -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
@@ -0,0 +1,20 @@
# Generated by Django 5.2.16 on 2026-09-15 04:24
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('ai', '0019_drop_llm_cost_accounting'),
('integrations', '0008_encrypted_column_width'),
]
operations = [
migrations.AddField(
model_name='aiagent',
name='transcription_integration',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='transcribing_agents', to='integrations.integration'),
),
]
@@ -0,0 +1,23 @@
# Generated by Django 5.2.16 on 2026-09-15 05:30
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('ai', '0020_aiagent_transcription_integration'),
]
operations = [
migrations.AddField(
model_name='aiagent',
name='transcription_model',
field=models.CharField(blank=True, default='', max_length=128),
),
migrations.AlterField(
model_name='aiagent',
name='model',
field=models.CharField(blank=True, default='', max_length=128),
),
]
@@ -0,0 +1,34 @@
"""Агент перестаёт дублировать модель интеграции.
Раньше модель копировалась на агента при каждом сохранении, и поле означало
«то же, что у интеграции». Теперь заполненное поле означает выбор человека:
агент отвечает именно этой моделью, даже если у интеграции другая по
умолчанию. Чтобы смена настройки провайдера не перестала доезжать до агентов,
которым модель никто не выбирал, совпадающее значение очищается — такие агенты
продолжают следовать за интеграцией.
"""
from django.db import migrations
def release_copied_models(apps, schema_editor):
AIAgent = apps.get_model("ai", "AIAgent")
updated = []
for agent in AIAgent.objects.select_related("provider_integration").exclude(model=""):
integration = agent.provider_integration
default_model = str((integration.config or {}).get("default_model") or "") if integration else ""
if agent.model == default_model:
agent.model = ""
updated.append(agent)
AIAgent.objects.bulk_update(updated, ["model"])
class Migration(migrations.Migration):
dependencies = [
("ai", "0021_aiagent_transcription_model_alter_aiagent_model"),
]
operations = [
migrations.RunPython(release_copied_models, migrations.RunPython.noop),
]
+20 -2
View File
@@ -334,7 +334,7 @@ class KnowledgeFragment(TenantRelationModel):
class AIAgent(TenantRelationModel):
tenant_relation_fields = ("channel", "provider_integration")
tenant_relation_fields = ("channel", "provider_integration", "transcription_integration")
channel = models.OneToOneField("channels.Channel", on_delete=models.CASCADE, related_name="ai_agent")
@@ -358,6 +358,18 @@ class AIAgent(TenantRelationModel):
)
# Чем расшифровывать голосовые. Обычно это тот же провайдер, что и отвечает,
# но не всегда: модель, которая пишет ответы, может не уметь речь в текст
# (у Anthropic и Yandex Foundation Models аудио-эндпоинта нет вовсе).
# Пусто — расшифровка идёт к провайдеру ответов, как было.
transcription_integration = models.ForeignKey(
"integrations.Integration",
on_delete=models.PROTECT,
related_name="transcribing_agents",
null=True,
blank=True,
)
name = models.CharField(max_length=255)
status = models.CharField(
@@ -372,7 +384,13 @@ class AIAgent(TenantRelationModel):
lifecycle_version = models.PositiveIntegerField(default=0)
model = models.CharField(max_length=128, default=DEFAULT_AI_MODEL)
# Модель ответов. Пусто — берётся модель по умолчанию из интеграции; так
# агент следует за настройкой провайдера. Заполнено — решает агент: на одном
# ключе живут разные агенты, и дорогая модель нужна не каждому.
model = models.CharField(max_length=128, blank=True, default="")
# Модель расшифровки голосовых. Пусто — модель из интеграции, которая
# расшифровывает, а если и там пусто — whisper-1.
transcription_model = models.CharField(max_length=128, blank=True, default="")
model_params = models.JSONField(default=dict, blank=True)
@@ -30,3 +30,16 @@ def get_provider(*, channel=None) -> LLMProvider:
t("ai.provider_not_configured")
)
return routing.resolve_provider(channel)
def get_transcription_provider(*, channel=None) -> LLMProvider:
"""Провайдер расшифровки голосовых.
Отличается от `get_provider` одним: агент может расшифровывать другим
провайдером, чем отвечает (chatballs.ai.provider.routing).
"""
if settings.CHATBALLS_AI_PROVIDER == "test":
return _test_provider()
if channel is None:
raise ProviderError(t("ai.provider_not_configured"))
return routing.resolve_transcription_provider(channel)
@@ -36,7 +36,12 @@ class OpenRouterProvider(LLMProvider):
def transcribe(self, *, audio: bytes, filename: str, content_type: str, model: str) -> str:
# OpenAI-совместимый POST /audio/transcriptions (whisper). Формат ответа
# {"text": "..."}; ошибки транслируются в ProviderError.
#
# Наружу уходит фраза для человека, а не ответ провайдера: оператору
# в ленте сообщений нечего делать с JSON чужого API. Сам ответ пишется
# в журнал — по нему разбирают настройку.
import json
import logging
import urllib.error
import urllib.request
@@ -44,6 +49,8 @@ class OpenRouterProvider(LLMProvider):
from chatballs.conversations.transports.base import multipart_body
from chatballs.integrations.proxy import build_opener
logger = logging.getLogger(__name__)
body, body_type = multipart_body(
{"model": model},
file_field="file",
@@ -65,9 +72,24 @@ class OpenRouterProvider(LLMProvider):
payload = json.loads(response.read().decode("utf-8"))
except urllib.error.HTTPError as error:
detail = error.read().decode("utf-8", "replace")[:300]
raise ProviderError(t("ai.transcription_failed_http", code=error.code, detail=detail)) from error
logger.warning(
"Transcription rejected by %s: HTTP %s %s (model=%s)",
self.base_url,
error.code,
detail,
model,
)
# 401/403 — ключ или доступ; 404 — у провайдера нет эндпоинта
# расшифровки (так отвечают Anthropic и Yandex Foundation Models);
# остальное — временный отказ, который лечится повтором.
if error.code in (401, 403):
raise ProviderError(t("ai.transcription_denied")) from error
if error.code == 404:
raise ProviderError(t("ai.transcription_unsupported")) from error
raise ProviderError(t("ai.transcription_failed")) from error
except (urllib.error.URLError, TimeoutError, OSError, json.JSONDecodeError) as error:
raise ProviderError(t("ai.transcription_failed", error=error)) from error
logger.warning("Transcription request to %s failed: %s", self.base_url, error)
raise ProviderError(t("ai.transcription_unreachable")) from error
text = str(payload.get("text") or "").strip()
if not text:
raise ProviderError(t("ai.empty_transcript"))
+38 -3
View File
@@ -57,6 +57,17 @@ def resolve_provider_and_model(channel, *, fallback_model: str) -> tuple[LLMProv
def resolve_model(channel, *, fallback_model: str) -> str:
"""Модель ответов: выбранная на карточке агента, иначе модель интеграции.
Порядок именно такой: ключ провайдера один на организацию, а агентов на нём
несколько, и модель — свойство агента, а не ключа. Пустое поле на карточке
означает «как у интеграции», поэтому агент, которому модель не назначали,
продолжает следовать за настройкой провайдера.
"""
agent = getattr(channel, "ai_agent", None)
chosen = str(getattr(agent, "model", "") or "").strip()
if chosen:
return chosen
integration = _channel_integration(channel)
return str(integration.config.get("default_model") or "").strip() or fallback_model
@@ -64,10 +75,34 @@ def resolve_model(channel, *, fallback_model: str) -> str:
DEFAULT_TRANSCRIPTION_MODEL = "whisper-1"
def _transcription_integration(channel) -> Integration:
"""Чем расшифровывать голосовые.
Обычно тем же провайдером, что и отвечает, но выбор отдельный: модель
ответов может не уметь речь в текст. У Anthropic и Yandex Foundation Models
эндпоинта `/audio/transcriptions` нет вовсе, и без отдельного выбора
голосовые у такого агента расшифровать было нечем.
"""
agent = getattr(channel, "ai_agent", None)
integration = getattr(agent, "transcription_integration", None) if agent else None
if integration is None or not integration.secret:
return _channel_integration(channel)
return integration
def resolve_transcription_provider(channel) -> LLMProvider:
"""Провайдер расшифровки: отдельная интеграция агента либо провайдер ответов."""
return _provider_from_integration(_transcription_integration(channel))
def resolve_transcription_model(channel) -> str:
"""Модель расшифровки голосовых из настроек AI-провайдера («Настройки →
AI-провайдер», поле «Модель расшифровки»); по умолчанию whisper-1."""
integration = _channel_integration(channel)
"""Модель расшифровки: выбранная на карточке агента, иначе модель той
интеграции, которая расшифровывает, иначе whisper-1."""
agent = getattr(channel, "ai_agent", None)
chosen = str(getattr(agent, "transcription_model", "") or "").strip()
if chosen:
return chosen
integration = _transcription_integration(channel)
return str(integration.config.get("transcription_model") or "").strip() or DEFAULT_TRANSCRIPTION_MODEL
@@ -56,3 +56,31 @@ def configure_agent_provider(
{"providerIntegrationId": t("ai.integration_model_required")}
)
return ProviderSelection(model, integration)
def configure_agent_transcription(
*, context: TenantContext, integration_id: int | None
) -> Integration | None:
"""Интеграция, которой агент расшифровывает голосовые.
Пусто — расшифровка идёт к провайдеру ответов. Модель для неё живёт в самой
интеграции («Модель расшифровки голосовых»), поэтому здесь проверяется
только, что интеграция принадлежит организации и умеет быть провайдером.
"""
if integration_id is None:
return None
try:
return Integration.objects.get(
id=integration_id,
organization_id=context.organization_id,
kind=IntegrationKind.LLM_PROVIDER,
provider__in=[
IntegrationProvider.OPENROUTER,
IntegrationProvider.CUSTOM,
IntegrationProvider.DEMO,
],
)
except (Integration.DoesNotExist, TypeError, ValueError) as error:
raise ValidationError(
{"transcriptionIntegrationId": t("ai.unknown_provider_integration")}
) from error
+23 -5
View File
@@ -9,7 +9,10 @@ from chatballs.ai.models import (
AIAgentStatus,
Knowledge,
)
from chatballs.ai.provider_selection import configure_agent_provider
from chatballs.ai.provider_selection import (
configure_agent_provider,
configure_agent_transcription,
)
from chatballs.channels.models import Channel
from chatballs.i18n import t
from chatballs.tenancy.context import TenantContext
@@ -19,6 +22,11 @@ from chatballs.tenancy.context import TenantContext
class AgentInput:
name: str
provider_integration_id: int | None
# Чем расшифровывать голосовые; None — тем же провайдером, что и отвечает.
transcription_integration_id: int | None
# Модели агента; пустая строка — «как в интеграции».
model: str
transcription_model: str
model_params: dict
allowed_tools: list
persona: str
@@ -90,7 +98,9 @@ def create_agent(*, context: TenantContext, data: AgentCreateInput) -> AIAgent:
channel=channel,
name=f"{channel.name} Agent",
status=AIAgentStatus.DRAFT,
model=selection.model,
# Модель новой карточки не фиксируется: агент следует за интеграцией,
# пока человек не выберет свою.
model="",
provider_integration=selection.integration,
persona=data.persona,
tone=data.tone,
@@ -124,11 +134,17 @@ def update_agent(*, context: TenantContext, agent: AIAgent, data: AgentInput) ->
context=context,
integration_id=data.provider_integration_id,
)
# Модель принадлежит интеграции; без провайдера прежняя модель сохраняется,
# чтобы PATCH инструкций не стирал её у черновика.
locked.model = selection.model if selection.integration else locked.model
# Модель выбирают на карточке агента: на одном ключе провайдера живут разные
# агенты, и модель им нужна разная. Пустое поле означает «как в интеграции»
# и разрешается в момент вызова (ai.provider.routing).
locked.model = data.model.strip()[:128]
locked.transcription_model = data.transcription_model.strip()[:128]
# Провайдер живёт на агенте: канал больше не изменяется при сохранении агента.
locked.provider_integration = selection.integration
locked.transcription_integration = configure_agent_transcription(
context=context,
integration_id=data.transcription_integration_id,
)
locked.model_params = data.model_params
locked.allowed_tools = data.allowed_tools
locked.persona = data.persona
@@ -139,7 +155,9 @@ def update_agent(*, context: TenantContext, agent: AIAgent, data: AgentInput) ->
update_fields=[
"name",
"model",
"transcription_model",
"provider_integration",
"transcription_integration",
"model_params",
"allowed_tools",
"persona",
+53 -2
View File
@@ -274,6 +274,55 @@ class AgentCardActivationTests(AgentCardTestCase):
),
)
def test_transcription_integration_is_chosen_separately(self) -> None:
# Модель ответов не обязана уметь речь в текст: у части провайдеров
# аудио-эндпоинта нет вовсе, поэтому расшифровку можно увести к другому.
from chatballs.integrations.models import IntegrationProvider
from chatballs.integrations.services import IntegrationInput, create_integration
from chatballs.testing import system_tenant_context
answering = self._byok_integration()
whisper = create_integration(
context=system_tenant_context(self.organization),
data=IntegrationInput(
provider=IntegrationProvider.CUSTOM,
name="Whisper",
secret="sk-whisper",
config={
"baseUrl": "https://api.groq.com/openai/v1",
"defaultModel": "any",
"transcriptionModel": "whisper-large-v3",
},
),
)
patched = self.client.patch(
f"/api/v1/agents/{self.card['id']}/",
data=json.dumps(
{
"providerIntegrationId": answering.id,
"transcriptionIntegrationId": whisper.id,
}
),
content_type="application/json",
)
self.assertEqual(patched.status_code, 200)
self.assertEqual(
patched.json()["agent"]["transcriptionIntegrationId"], whisper.id
)
agent = AIAgent.objects.get(id=self.card["aiAgentId"])
self.assertEqual(agent.transcription_integration_id, whisper.id)
self.assertEqual(agent.provider_integration_id, answering.id)
cleared = self.client.patch(
f"/api/v1/agents/{self.card['id']}/",
data=json.dumps({"transcriptionIntegrationId": None}),
content_type="application/json",
)
self.assertEqual(cleared.status_code, 200)
self.assertIsNone(cleared.json()["agent"]["transcriptionIntegrationId"])
def test_activation_without_provider_integration_is_rejected(self) -> None:
# Активация требует выбранного провайдера организации (ADR-CHATBALLS-0042 §2);
# деактивация свободна.
@@ -292,8 +341,10 @@ class AgentCardActivationTests(AgentCardTestCase):
content_type="application/json",
)
self.assertEqual(patched.status_code, 200)
# Модель принадлежит интеграции: агент получает её default_model.
self.assertEqual(patched.json()["agent"]["model"], "byok-model")
# Своей модели у агента нет — он следует за интеграцией, и карточка
# показывает её модель подсказкой.
self.assertEqual(patched.json()["agent"]["model"], "")
self.assertEqual(patched.json()["agent"]["providerModel"], "byok-model")
activated = self.client.post(f"/api/v1/agents/{self.card['id']}/activate/")
self.assertEqual(activated.status_code, 200)
@@ -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())
@@ -58,7 +58,10 @@ class ProviderModeTests(TestCase):
)
agent = self.channel.ai_agent
agent.provider_integration = integration
agent.save(update_fields=["provider_integration"])
# Пустая модель на агенте означает «как в интеграции» — именно так живёт
# агент, которому модель не выбирали на карточке.
agent.model = ""
agent.save(update_fields=["provider_integration", "model"])
self.channel.refresh_from_db()
return integration
@@ -199,7 +202,9 @@ class AgentProviderOwnershipTests(TestCase):
self.channel.provider_integration = integration
self.channel.save(update_fields=["provider_integration"])
self.agent.provider_integration = None
self.agent.save(update_fields=["provider_integration"])
# Агент следует за интеграцией: своей модели у него нет.
self.agent.model = ""
self.agent.save(update_fields=["provider_integration", "model"])
self.channel.refresh_from_db()
self.assertEqual(
@@ -220,9 +225,23 @@ class AgentProviderOwnershipTests(TestCase):
),
)
self.agent.provider_integration = current
self.agent.save(update_fields=["provider_integration"])
self.agent.model = ""
self.agent.save(update_fields=["provider_integration", "model"])
self.channel.refresh_from_db()
self.assertEqual(
resolve_model(self.channel, fallback_model="agent-model"), "current-model"
)
def test_model_chosen_on_the_card_wins_over_the_integration(self) -> None:
# Ключ провайдера один на организацию, агентов на нём несколько: модель
# выбирают агенту, и она не должна теряться при смене настройки ключа.
integration = self._integration(default_model="integration-model")
self.agent.provider_integration = integration
self.agent.model = "own-model"
self.agent.save(update_fields=["provider_integration", "model"])
self.channel.refresh_from_db()
self.assertEqual(
resolve_model(self.channel, fallback_model="agent-model"), "own-model"
)
@@ -0,0 +1,200 @@
"""Расшифровка голосовых может идти не к тому провайдеру, который отвечает.
Модель ответов часто не умеет речь в текст: у Anthropic и Yandex Foundation
Models эндпоинта `/audio/transcriptions` нет вовсе. Поэтому интеграция для
расшифровки выбирается на агенте отдельно.
"""
import json
import urllib.error
from io import BytesIO
from unittest import mock
from django.test import TestCase
from chatballs.ai.provider.base import ProviderError
from chatballs.ai.provider.custom import CustomProvider
from chatballs.ai.provider.routing import (
resolve_model,
resolve_transcription_model,
resolve_transcription_provider,
)
from chatballs.ai.tests import make_channel_with_agent
from chatballs.identity.bootstrap import bootstrap_owner
from chatballs.identity.models import Organization
from chatballs.integrations.models import IntegrationProvider
from chatballs.integrations.services import IntegrationInput, create_integration
from chatballs.testing import system_tenant_context
class TranscriptionRoutingTests(TestCase):
def setUp(self) -> None:
bootstrap_owner(email="owner@example.com", password="temporary-password")
self.organization = Organization.objects.get(slug="demo")
self.context = system_tenant_context(self.organization)
self.channel, self.agent = make_channel_with_agent(
self.organization, code="voice-agent", name="Голосовой агент"
)
def _integration(self, *, name: str, base_url: str, transcription_model: str = ""):
config = {"baseUrl": base_url, "defaultModel": "answer-model"}
if transcription_model:
config["transcriptionModel"] = transcription_model
return create_integration(
context=self.context,
data=IntegrationInput(
provider=IntegrationProvider.CUSTOM,
name=name,
secret="sk-key",
config=config,
),
)
def test_without_a_choice_transcription_goes_to_the_answering_provider(self) -> None:
answering = self._integration(
name="Ответы", base_url="https://answers.example.test/v1"
)
self.agent.provider_integration = answering
self.agent.save(update_fields=["provider_integration"])
self.channel.refresh_from_db()
provider = resolve_transcription_provider(self.channel)
self.assertIsInstance(provider, CustomProvider)
self.assertEqual(provider.base_url, "https://answers.example.test/v1")
def test_chosen_integration_takes_the_voice(self) -> None:
answering = self._integration(
name="Ответы", base_url="https://answers.example.test/v1"
)
whisper = self._integration(
name="Whisper",
base_url="https://whisper.example.test/v1",
transcription_model="whisper-large-v3",
)
self.agent.provider_integration = answering
self.agent.transcription_integration = whisper
self.agent.save(
update_fields=["provider_integration", "transcription_integration"]
)
self.channel.refresh_from_db()
provider = resolve_transcription_provider(self.channel)
self.assertEqual(provider.base_url, "https://whisper.example.test/v1")
self.assertEqual(resolve_transcription_model(self.channel), "whisper-large-v3")
def test_model_defaults_to_whisper_of_the_chosen_integration(self) -> None:
answering = self._integration(
name="Ответы",
base_url="https://answers.example.test/v1",
transcription_model="answer-side-model",
)
whisper = self._integration(
name="Whisper", base_url="https://whisper.example.test/v1"
)
self.agent.provider_integration = answering
self.agent.transcription_integration = whisper
self.agent.save(
update_fields=["provider_integration", "transcription_integration"]
)
self.channel.refresh_from_db()
self.assertEqual(resolve_transcription_model(self.channel), "whisper-1")
class TranscriptionModelTests(TranscriptionRoutingTests):
"""Модель расшифровки тоже выбирается на агенте, а не только в интеграции."""
def test_model_from_the_card_wins(self) -> None:
whisper = self._integration(
name="Whisper",
base_url="https://whisper.example.test/v1",
transcription_model="whisper-large-v3",
)
self.agent.provider_integration = whisper
self.agent.transcription_integration = whisper
self.agent.transcription_model = "gpt-4o-mini-transcribe"
self.agent.save(
update_fields=[
"provider_integration",
"transcription_integration",
"transcription_model",
]
)
self.channel.refresh_from_db()
self.assertEqual(
resolve_transcription_model(self.channel), "gpt-4o-mini-transcribe"
)
def test_text_and_voice_models_are_independent(self) -> None:
answering = self._integration(
name="Ответы", base_url="https://answers.example.test/v1"
)
whisper = self._integration(
name="Whisper", base_url="https://whisper.example.test/v1"
)
self.agent.provider_integration = answering
self.agent.transcription_integration = whisper
self.agent.model = "yandexgpt/rc"
self.agent.transcription_model = "whisper-large-v3-turbo"
self.agent.save(
update_fields=[
"provider_integration",
"transcription_integration",
"model",
"transcription_model",
]
)
self.channel.refresh_from_db()
self.assertEqual(resolve_model(self.channel, fallback_model=""), "yandexgpt/rc")
self.assertEqual(
resolve_transcription_model(self.channel), "whisper-large-v3-turbo"
)
self.assertEqual(
resolve_transcription_provider(self.channel).base_url,
"https://whisper.example.test/v1",
)
class TranscriptionErrorTextTests(TestCase):
"""Оператору — фраза, провайдеру — журнал: сырого ответа API в ленте нет."""
def _provider(self) -> CustomProvider:
return CustomProvider(
api_key="sk-key", base_url="https://api.example.test/v1", timeout=5
)
def _fail_with(self, code: int, body: bytes):
error = urllib.error.HTTPError(
"https://api.example.test/v1/audio/transcriptions",
code,
"error",
{},
BytesIO(body),
)
return mock.patch(
"chatballs.integrations.proxy.build_opener",
return_value=mock.Mock(open=mock.Mock(side_effect=error)),
)
def _transcribe(self):
return self._provider().transcribe(
audio=b"0" * 16, filename="voice.ogg", content_type="audio/ogg", model="m"
)
def test_denied_request_does_not_leak_the_provider_answer(self) -> None:
body = json.dumps(
{"error": {"message": "Subscription is not supported for service accounts"}}
).encode()
with self._fail_with(403, body), self.assertRaises(ProviderError) as caught:
self._transcribe()
message = str(caught.exception)
self.assertNotIn("Subscription", message)
self.assertNotIn("403", message)
self.assertIn("ключ", message)
def test_missing_endpoint_tells_where_to_look(self) -> None:
with self._fail_with(404, b"not found"), self.assertRaises(ProviderError) as caught:
self._transcribe()
self.assertIn("расшифров", str(caught.exception).lower())
+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
@@ -9,6 +9,7 @@ from urllib.parse import parse_qs, urlparse
from django.utils import timezone
from chatballs.calls.event_handlers import CallInviteDeliveryError, handle_call_invite_send
from chatballs.calls.lifecycle import transition_call
from chatballs.calls.models import (
CallEndedBy,
CallInvite,
@@ -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(), [])
@@ -18,6 +18,7 @@ from django.db.models import (
)
from django.db.models.functions import Coalesce
from chatballs.conversations.contact_avatars import contact_avatar_url_in
from chatballs.conversations.models import (
ConnectionIdentity,
Contact,
@@ -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)
+21 -5
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,
@@ -99,7 +100,7 @@ class TranscriptionJob:
def prepare_transcription(channel, message: Message) -> TranscriptionJob | None:
"""Шаг в транзакции: провайдер организации, модель и байты аудио."""
from chatballs.ai.provider.factory import get_provider
from chatballs.ai.provider.factory import get_transcription_provider
from chatballs.ai.provider.routing import (
DEFAULT_TRANSCRIPTION_MODEL,
resolve_transcription_model,
@@ -107,7 +108,7 @@ def prepare_transcription(channel, message: Message) -> TranscriptionJob | None:
if not message.audio:
return None
provider = get_provider(channel=channel)
provider = get_transcription_provider(channel=channel)
try:
model = resolve_transcription_model(channel)
except ProviderError:
@@ -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"
@@ -1,5 +1,6 @@
from django.db.models import Count, Max, Q
from chatballs.conversations.contact_avatars import contact_avatar_url_in
from chatballs.conversations.models import (
ConnectionIdentity,
Conversation,
@@ -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"
+9 -2
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",
@@ -371,8 +373,10 @@ MESSAGES: dict[str, object] = {
"conversations.activity_started": "Conversation · {channel}",
"conversations.field_too_long": "Field {field}: no longer than {limit} characters",
"ai.provider_no_transcription": "The {provider} provider does not support audio transcription",
"ai.transcription_failed": "Transcription failed: {error}",
"ai.transcription_failed_http": "Transcription failed: HTTP {code} {detail}",
"ai.transcription_denied": "The provider refused the transcription request: check the key and the access to the transcription model in the integration settings",
"ai.transcription_failed": "The provider could not transcribe the recording. Try again",
"ai.transcription_unreachable": "The transcription provider is unavailable. Try again",
"ai.transcription_unsupported": "This provider cannot transcribe speech. Pick another integration for transcription on the agent card",
"api.expected_integer": "{name}: an integer is expected",
"api.expected_positive": "{name}: a number greater than zero is expected",
"api.expected_record_id": "{name}: a record identifier is expected",
@@ -499,6 +503,9 @@ MESSAGES: dict[str, object] = {
"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",
"integrations.check_rejected": "The provider refused the check ({status})",
"integrations.check_rejected_access": "The provider refused the check ({status}): check the key and whether the provider is reachable from your network — region, proxy",
"integrations.check_rejected_reason": "The provider refused the check ({status}): {reason}",
"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",
+9 -2
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": "Диалог закрыт",
@@ -375,8 +377,10 @@ MESSAGES: dict[str, object] = {
"conversations.activity_started": "Диалог · {channel}",
"conversations.field_too_long": "Поле {field}: не длиннее {limit} символов",
"ai.provider_no_transcription": "Провайдер {provider} не поддерживает расшифровку аудио",
"ai.transcription_failed": "Расшифровка не удалась: {error}",
"ai.transcription_failed_http": "Расшифровка не удалась: HTTP {code} {detail}",
"ai.transcription_denied": "Провайдер не принял запрос на расшифровку: проверьте ключ и доступ к модели расшифровки в настройках интеграции",
"ai.transcription_failed": "Провайдер не смог расшифровать запись. Попробуйте ещё раз",
"ai.transcription_unreachable": "Провайдер расшифровки недоступен. Попробуйте ещё раз",
"ai.transcription_unsupported": "Этот провайдер не умеет расшифровывать речь. Выберите на карточке агента другую интеграцию для расшифровки",
"api.expected_integer": "{name}: ожидается целое число",
"api.expected_positive": "{name}: ожидается число больше нуля",
"api.expected_record_id": "{name}: ожидается идентификатор записи",
@@ -503,6 +507,9 @@ MESSAGES: dict[str, object] = {
"notifications.type_dialog_assigned": "Диалог назначили на меня",
"notifications.type_dialog_waiting_long": "Диалог долго ждёт человека",
"notifications.type_ai_stopped": "AI остановлен ошибкой",
"integrations.check_rejected": "Провайдер отклонил проверку ({status})",
"integrations.check_rejected_access": "Провайдер отклонил проверку ({status}): проверьте ключ и доступность провайдера из вашей сети — регион, прокси",
"integrations.check_rejected_reason": "Провайдер отклонил проверку ({status}): {reason}",
"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),
},
+54 -1
View File
@@ -16,6 +16,8 @@ from __future__ import annotations
import imaplib
import json
import logging
import re
import smtplib
import urllib.error
import urllib.request
@@ -25,6 +27,8 @@ from django.conf import settings
from chatballs.i18n import t, tn
from chatballs.integrations.proxy import build_opener
logger = logging.getLogger(__name__)
DEFAULT_OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1"
# platform-api2.max.ru отдаёт неполную цепочку сертификата (verify failed);
# рабочий и с валидным сертификатом — platform-api.max.ru.
@@ -46,11 +50,60 @@ def _get(url: str, *, headers: dict[str, str] | None = None, proxy_url: str = ""
return response.status, data
def _error_reason(body: str) -> str:
"""Короткая причина из ответа провайдера.
Ответ бывает и JSON'ом провайдера, и HTML-страницей защиты перед ним —
человеку нужна одна фраза, а не то и другое целиком.
"""
try:
payload = json.loads(body)
except (json.JSONDecodeError, TypeError):
payload = None
if isinstance(payload, dict):
error = payload.get("error")
if isinstance(error, dict):
return str(error.get("message") or "")[:160]
for key in ("message", "detail", "error_description"):
if payload.get(key):
return str(payload[key])[:160]
if isinstance(error, str):
return error[:160]
text = re.sub(r"<[^>]+>", " ", body)
text = " ".join(text.split())
return text[:160]
def _http_failure(error: urllib.error.HTTPError) -> str:
"""Отказ провайдера словами, а не кодом.
Голый «HTTP 403» не говорит ничего: так отвечают и на чужой ключ, и на
запрос из закрытого региона, и на блокировку самого прокси. Причину, если
провайдер её назвал, показываем сразу; ответ целиком уходит в журнал.
"""
try:
body = error.read().decode("utf-8", "replace")
except (OSError, ValueError):
body = ""
logger.warning(
"Integration check rejected: HTTP %s %s — %s",
error.code,
getattr(error, "url", ""),
body[:500],
)
reason = _error_reason(body)
if reason:
return t("integrations.check_rejected_reason", status=error.code, reason=reason)
if error.code in (401, 403):
return t("integrations.check_rejected_access", status=error.code)
return t("integrations.check_rejected", status=error.code)
def _safe(fn) -> CheckResult:
try:
return fn()
except urllib.error.HTTPError as error:
return False, f"HTTP {error.code}: {error.reason}", {}
return False, _http_failure(error), {}
except (urllib.error.URLError, TimeoutError, OSError) as error:
return False, t("integrations.check_no_connection", error=error), {}
+26 -4
View File
@@ -18,6 +18,8 @@ import ssl
import urllib.parse
import urllib.request
from django.conf import settings
from chatballs.i18n import t
from chatballs.integrations.outbound import OutboundUrlRejected
@@ -85,6 +87,24 @@ def _blocked_scheme_handlers() -> list[urllib.request.BaseHandler]:
return [_RefusedFileHandler(), _RefusedFTPHandler(), _RefusedDataHandler()]
def user_agent() -> str:
"""Чем продукт представляется чужим API.
Умолчание urllib — «Python-urllib/3.x», и защита перед API (Cloudflare)
банит такой запрос до того, как его увидит сам провайдер: на бою это
выглядело как 403 «error code: 1010» у провайдера, который через тот же
прокси прекрасно отвечает браузеру. С обычным именем клиента запрос
проходит. Заголовок ставится на opener, поэтому свой User-Agent
конкретного запроса он не перебивает.
"""
return f"Chatballs/{getattr(settings, 'CHATBALLS_VERSION', 'dev')}"
def _named(opener):
opener.addheaders = [("User-Agent", user_agent())]
return opener
def build_opener(proxy_url: str, *, validate_redirect=None):
"""urllib opener, проксирующий http/https/socks5 запросы.
@@ -96,12 +116,14 @@ def build_opener(proxy_url: str, *, validate_redirect=None):
if validate_redirect is not None:
blocked.append(_GuardedRedirectHandler(validate_redirect))
if not proxy_url:
return urllib.request.build_opener(*blocked)
return _named(urllib.request.build_opener(*blocked))
scheme = urllib.parse.urlparse(proxy_url).scheme.lower()
if scheme in SOCKS_SCHEMES:
return urllib.request.build_opener(_SocksProxyHandler(proxy_url), *blocked)
return urllib.request.build_opener(
urllib.request.ProxyHandler({"http": proxy_url, "https": proxy_url}), *blocked
return _named(urllib.request.build_opener(_SocksProxyHandler(proxy_url), *blocked))
return _named(
urllib.request.build_opener(
urllib.request.ProxyHandler({"http": proxy_url, "https": proxy_url}), *blocked
)
)
@@ -423,6 +423,81 @@ class CheckProxyTransportTests(TestCase):
class OutboundUserAgentTests(TestCase):
"""Продукт представляется своим именем: «Python-urllib» защита перед чужим
API банит до самого API (на бою — Cloudflare «error code: 1010»)."""
def test_opener_introduces_the_product(self) -> None:
from chatballs.integrations.proxy import build_opener, user_agent
for proxy in ("", "http://proxy:8080"):
with self.subTest(proxy=proxy or "без прокси"):
agents = dict(build_opener(proxy).addheaders)
self.assertEqual(agents["User-Agent"], user_agent())
self.assertNotIn("urllib", agents["User-Agent"])
def test_request_keeps_its_own_agent(self) -> None:
import urllib.request
from chatballs.integrations.proxy import build_opener
request = urllib.request.Request(
"https://api.example.test/v1/models", headers={"User-Agent": "Mine/1.0"}
)
opener = build_opener("")
# urllib добавляет заголовки opener'а только к тем, которых нет в запросе.
self.assertEqual(request.get_header("User-agent"), "Mine/1.0")
self.assertTrue(any(name == "User-Agent" for name, _ in opener.addheaders))
class CheckFailureTextTests(TestCase):
"""Отказ провайдера объясняется словами: голый код ничего не говорит."""
def _reject(self, code: int, body: bytes):
import urllib.error
from io import BytesIO
error = urllib.error.HTTPError(
"https://api.example.test/v1/models", code, "Forbidden", {}, BytesIO(body)
)
return mock.patch(
"chatballs.integrations.checks.build_opener",
return_value=mock.Mock(open=mock.Mock(side_effect=error)),
)
def test_reason_from_the_provider_reaches_the_screen(self) -> None:
body = json.dumps(
{"error": {"message": "Your API key is invalid"}}
).encode()
with self._reject(403, body):
ok, detail, _meta = checks.check_custom(
secret="sk-test", base_url="https://api.example.test/v1"
)
self.assertFalse(ok)
self.assertIn("Your API key is invalid", detail)
self.assertIn("403", detail)
def test_html_block_page_is_squeezed_into_one_line(self) -> None:
body = b"<html><head><title>Access denied</title></head><body><h1>Sorry, you have been blocked</h1></body></html>"
with self._reject(403, body):
ok, detail, _meta = checks.check_custom(
secret="sk-test", base_url="https://api.example.test/v1"
)
self.assertFalse(ok)
self.assertNotIn("<", detail)
self.assertIn("blocked", detail.lower())
def test_silent_refusal_tells_where_to_look(self) -> None:
with self._reject(403, b""):
ok, detail, _meta = checks.check_custom(
secret="sk-test", base_url="https://api.example.test/v1"
)
self.assertFalse(ok)
self.assertIn("регион", detail)
class OpenRouterProviderProxyTests(TestCase):
def test_provider_routes_through_proxy_handler(self) -> None:
+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,
@@ -5,7 +5,7 @@ import { LANGUAGES } from "@chatballs/shared";
import { api, ApiError } from "../../api/client";
import { ChannelGlyph } from "../../shared/badges";
import { SelectField } from "../../shared/form-controls";
import { FormField, SelectField } from "../../shared/form-controls";
import { Icon } from "../../shared/icons";
import { EmptyState, ErrorScreen, LoadingState } from "../../shared/ui";
import { BackLink, Button, CopyButton } from "../../shared/ui-controls";
@@ -534,6 +534,13 @@ function ModelCard({ card, providers, canManage, busy, apply }: {
}) {
const missingProvider = card.providerIntegrationId === null;
const providerName = providers.find((item) => item.id === card.providerIntegrationId)?.name ?? "";
const transcriptionName = providers.find((item) => item.id === card.transcriptionIntegrationId)?.name ?? "";
// Поля моделей редактируются свободно и уходят на сервер по потере фокуса:
// сохранять каждую букву — это запрос на символ.
const [modelDraft, setModelDraft] = useState(card.model);
const [transcriptionDraft, setTranscriptionDraft] = useState(card.transcriptionModel);
useEffect(() => { setModelDraft(card.model); }, [card.model]);
useEffect(() => { setTranscriptionDraft(card.transcriptionModel); }, [card.transcriptionModel]);
return (
<section className="agent-card is-side">
@@ -550,13 +557,38 @@ function ModelCard({ card, providers, canManage, busy, apply }: {
onChange={(next) => void apply({ providerIntegrationId: next ? Number(next) : null })}
options={[["", t("ai.not_selected")], ...providers.map((item) => [String(item.id), item.name] as [string, string])]}
/>
<label className="agent-field is-model">
<span>{t("common.model")}</span>
<span className="agent-field-control">
<span className={`agent-field-static ${missingProvider ? "is-placeholder" : ""}`}>{missingProvider ? t("ai.pick_provider") : card.model}</span>
<Icon name="search" size={14} strokeWidth={1.8} />
</span>
</label>
{/* Ключ провайдера один на организацию, а агентов на нём несколько:
модель принадлежит агенту. Пустое поле — «как в интеграции», и
подсказкой в нём стоит её модель. */}
<FormField
disabled={busy || !canManage}
label={t("common.model")}
mono
placeholder={missingProvider ? t("ai.pick_provider") : card.providerModel || t("ai.model_of_integration")}
value={modelDraft}
onChange={setModelDraft}
onBlur={() => { if (modelDraft !== card.model) void apply({ model: modelDraft }); }}
/>
{/* Речь в текст умеет не всякая модель, которой агент отвечает: у части
провайдеров аудио-эндпоинта нет вовсе. Поэтому выбор отдельный. */}
<SelectField
disabled={busy}
label={t("ai.transcription_provider")}
readOnly={!canManage}
readOnlyText={transcriptionName || t("ai.same_as_answers")}
value={card.transcriptionIntegrationId ? String(card.transcriptionIntegrationId) : ""}
onChange={(next) => void apply({ transcriptionIntegrationId: next ? Number(next) : null })}
options={[["", t("ai.same_as_answers")], ...providers.map((item) => [String(item.id), item.name] as [string, string])]}
/>
<FormField
disabled={busy || !canManage}
label={t("ai.transcription_model")}
mono
placeholder={card.transcriptionProviderModel || t("ai.model_of_integration")}
value={transcriptionDraft}
onChange={setTranscriptionDraft}
onBlur={() => { if (transcriptionDraft !== card.transcriptionModel) void apply({ transcriptionModel: transcriptionDraft }); }}
/>
</div>
</section>
);
@@ -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),
};
}
+14 -2
View File
@@ -35,7 +35,14 @@ export type AgentCard = {
groupColor: string;
aiStatus: AgentAiStatus;
model: string;
/** Модель ответов; пусто — как в интеграции. */
transcriptionModel: string;
/** Модели, заданные в интеграциях: подсказка в пустом поле. */
providerModel: string;
transcriptionProviderModel: string;
providerIntegrationId: number | null;
/** Чем расшифровывать голосовые; null — тем же провайдером, что отвечает. */
transcriptionIntegrationId: number | null;
modelParams: Record<string, unknown>;
// Режим языка ответов: MIRROR, ORGANIZATION или код языка.
answerLanguage: string;
@@ -57,6 +64,9 @@ export type AgentPatch = Partial<{
groupId: number | null;
isActive: boolean;
providerIntegrationId: number | null;
transcriptionIntegrationId: number | null;
model: string;
transcriptionModel: string;
answerLanguage: string;
persona: string;
tone: string;
@@ -155,9 +165,11 @@ export function agentTile(card: Pick<AgentCard, "id" | "isActive">): { color: st
return { color, background: `color-mix(in srgb, ${color} 12%, var(--surface-card))` };
}
/** Модель в списке: пока провайдер не выбран, показывать нечего (кадр G1). */
/** Модель в списке: своя модель агента, иначе та, что стоит в интеграции.
* Пока провайдер не выбран, показывать нечего (кадр G1). */
export function agentModelLabel(card: AgentCard): string {
return card.providerIntegrationId ? card.model : "—";
if (!card.providerIntegrationId) return "—";
return card.model || card.providerModel || "—";
}
export function agentTint(provider: string): { color: string; bg: string; full: string } {
@@ -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}
@@ -0,0 +1,32 @@
import { describe, expect, it } from "vitest";
import { resolveAudioMode } from "./AudioCallOverlay";
import type { ApiCall } from "./model";
// Экран аудиозвонка у оператора: что он показывает в каждой фазе.
function call(status: ApiCall["status"]): ApiCall {
return { id: "c1", conversationId: 1, status, kind: "AUDIO", connectedAt: null, endedBy: null, durationSeconds: null } as ApiCall;
}
describe("resolveAudioMode", () => {
it("показывает разговор, пока звонок идёт", () => {
expect(resolveAudioMode(call("ACTIVE"), "", "connected", "none")).toBe("active");
});
it("считает звонок завершённым, даже когда соединение ещё считает себя живым", () => {
// Клиент положил трубку: сервер уже в терминале, а peer connection узнаёт
// об этом позже — или не узнаёт вовсе, если сокет мёртв.
expect(resolveAudioMode(call("ENDED"), "", "connected", "none")).toBe("status");
expect(resolveAudioMode(call("DECLINED"), "", "connected", "none")).toBe("status");
expect(resolveAudioMode(call("FAILED"), "", "connected", "none")).toBe("status");
});
it("до ответа клиента звонок исходящий", () => {
expect(resolveAudioMode(call("RINGING"), "", "idle", "none")).toBe("ringing");
});
it("принятый звонок без соединения — соединение", () => {
expect(resolveAudioMode(call("ACCEPTED"), "", "idle", "none")).toBe("connecting");
});
});
@@ -63,11 +63,26 @@ export function AudioCallOverlay(props: Props) {
: builtStatus;
const onAccept = () => { void rtc.start(); };
// «Завершить» обязано сработать всегда. Токен доступа есть не в каждом
// состоянии (карточку звонка могли открыть из списка), а сам запрос может
// упасть — тогда звонок заканчивается вторым путём, по сессии оператора.
const finish = async () => {
if (!call) return;
// Звонок уже закончил кто-то другой — завершать нечего, окно закрывается.
if (isTerminalCallStatus(call.status)) {
rtc.stop();
props.onClose();
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 () => {
@@ -114,16 +129,21 @@ export function AudioCallOverlay(props: Props) {
);
}
function resolveAudioMode(call: ApiCall | null, errorText: string, connection: string, mediaIssue: string): AudioCallMode {
export function resolveAudioMode(call: ApiCall | null, errorText: string, connection: string, mediaIssue: string): AudioCallMode {
if (errorText || !call) return "status";
if (mediaIssue === "devices" || mediaIssue === "unsupported") return "status";
// Завершённый звонок завершён, чем бы ни было занято RTC-соединение. Клиент
// кладёт трубку, сервер переводит звонок в терминал, а peer connection у
// оператора об этом узнаёт позже — а если сокет уже мёртв, то и не узнаёт
// вовсе. Пока эта проверка стояла последней, у оператора оставался экран
// активного разговора с кнопкой «Завершить», которой нечего было завершать.
if (isTerminalCallStatus(call.status)) return "status";
// Звонок всегда инициирует оператор, поэтому до ответа клиента это исходящий.
if (call.status === "REQUESTED" || call.status === "RINGING") return "ringing";
if (call.status === "ACCEPTED") return connection === "connected" ? "active" : "connecting";
if (connection === "reconnecting") return "reconnecting";
if (connection === "failed") return "status";
if (call.status === "ACTIVE" || connection === "connected") return "active";
if (isTerminalCallStatus(call.status)) return "status";
return "connecting";
}
@@ -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,25 @@ 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) return;
// Звонок уже закончил кто-то другой — завершать нечего, окно закрывается.
if (TERMINAL[call.status]) {
rtc.stop();
props.onClose();
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) =>
@@ -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;
+12 -4
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,8 +383,8 @@ 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.model_of_integration": "Same as in the integration",
"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.",
"ai.name_unique_within_its_parent": "The name is unique within its parent.",
@@ -421,6 +423,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}",
@@ -434,10 +437,12 @@ export const en: Record<MessageKey, Message> = {
"ai.replies_2": "IN REPLIES",
"ai.rules": "Rules",
"ai.save_reindex": "Save and reindex",
"ai.same_as_answers": "Same as answers",
"ai.saving": "Saving…",
"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",
@@ -487,6 +492,8 @@ export const en: Record<MessageKey, Message> = {
"ai.up_25_mb": "· up to 25 MB",
"ai.update": "update",
"ai.updated": "· Updated:",
"ai.transcription_model": "Transcription model",
"ai.transcription_provider": "Voice transcription",
"ai.updated_2": "UPDATED",
"ai.updated_by_at": "updated by {name} · {date}",
"ai.updated_on": "updated {date}",
@@ -649,11 +656,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",
@@ -1665,6 +1672,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",
+12 -4
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,8 +384,8 @@ export const ru = {
"ai.list": "Список",
"ai.manage": "Управлять",
"ai.material_import": "Импорт материалов",
"ai.missing_category_path": "категории «{name}» нет — импорт не создаёт категории. Создайте её или уберите последний уровень пути.",
"ai.move": "Переместить",
"ai.model_of_integration": "Как в интеграции",
"ai.move_category": "Переместить в категорию",
"ai.name_group_all_takes_start": "Имя и группа — всё, что нужно для старта. Инструкции, знания и подключения настраиваются потом на карточке.",
"ai.name_unique_within_its_parent": "Имя уникально внутри родителя.",
@@ -422,6 +424,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}",
@@ -435,10 +438,12 @@ export const ru = {
"ai.replies_2": "В ОТВЕТАХ",
"ai.rules": "Правила",
"ai.save_reindex": "Сохранить и переиндексировать",
"ai.same_as_answers": "Как у ответов",
"ai.saving": "Сохранение…",
"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": "Выбрать знания",
@@ -488,6 +493,8 @@ export const ru = {
"ai.up_25_mb": "· до 25 МБ",
"ai.update": "обновить",
"ai.updated": "· Обновлено:",
"ai.transcription_model": "Модель расшифровки",
"ai.transcription_provider": "Расшифровка голосовых",
"ai.updated_2": "ОБНОВЛЕНО",
"ai.updated_by_at": "обновил {name} · {date}",
"ai.updated_on": "обновлено {date}",
@@ -650,11 +657,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": "Диалоги появятся, когда клиенты напишут вашему агенту",
@@ -1666,6 +1673,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": "Предыдущая страница",
@@ -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;
}
}
@@ -9,6 +9,9 @@ type FormFieldProps = {
label: string;
mono?: boolean;
onChange?: (value: string) => void;
/** Значение уходит на сервер по потере фокуса: запрос на каждую букву — это
* запрос на каждую букву. */
onBlur?: () => void;
placeholder?: string;
type?: "password" | "text";
value: string;
@@ -21,6 +24,7 @@ export function FormField({
label,
mono = false,
onChange,
onBlur,
placeholder = "",
type = "text",
value,
@@ -39,7 +43,7 @@ export function FormField({
return (
<label className={className}>
<span>{label}</span>
<input className={mono ? "mono" : ""} type={type} value={value} placeholder={placeholder} disabled={disabled} readOnly={!editable} onChange={(event) => onChange?.(event.target.value)} />
<input className={mono ? "mono" : ""} type={type} value={value} placeholder={placeholder} disabled={disabled} readOnly={!editable} onChange={(event) => onChange?.(event.target.value)} onBlur={onBlur} />
{error && <small className="form-field-error" role="alert">{error}</small>}
</label>
);
@@ -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;
}
@@ -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 };
}
+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": "Убрать файл",
Loaded 100 of 110 files, more files were not shown because too many files have changed in this diff. Show more