mirror of
https://github.com/dartdavros/chatballs.git
synced 2026-10-05 17:14:59 +03:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
129e4a40ec | ||
|
|
9b54c544af | ||
|
|
e8155db4f9 | ||
|
|
d9bc540bc3 | ||
|
|
1de6239eb4 | ||
|
|
73ed0ae6cd | ||
|
|
8d334de3f3 | ||
|
|
733b44f186 | ||
|
|
5dbc062b1e | ||
|
|
ee8367e205 | ||
|
|
839a1fe815 | ||
|
|
c62cb903ab |
No files matched your search
@@ -51,6 +51,9 @@
|
||||
import surfaces
|
||||
}
|
||||
|
||||
# Дополнительные сайты из постоянного тома сохраняются при обновлении шлюза.
|
||||
import /config/sites/*.caddy
|
||||
|
||||
# Любой хост, которому ask-эндпоинт разрешил сертификат.
|
||||
https:// {
|
||||
encode gzip zstd
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -66,14 +66,11 @@ def _category_for_path(*, context: TenantContext, raw_path: object) -> Knowledge
|
||||
for raw_name in raw_path:
|
||||
if not isinstance(raw_name, str) or not raw_name.strip():
|
||||
raise ValidationError({"categoryPath": t("ai.category_names_strings")})
|
||||
try:
|
||||
category = KnowledgeCategory.objects.get(
|
||||
organization_id=context.organization_id,
|
||||
parent_id=parent_id,
|
||||
name=raw_name.strip(),
|
||||
)
|
||||
except KnowledgeCategory.DoesNotExist as error:
|
||||
raise ValidationError({"categoryPath": t("ai.category_path_not_found")}) from error
|
||||
category, _ = KnowledgeCategory.objects.get_or_create(
|
||||
organization_id=context.organization_id,
|
||||
parent_id=parent_id,
|
||||
name=raw_name.strip(),
|
||||
)
|
||||
parent_id = category.id
|
||||
assert category is not None
|
||||
return category
|
||||
|
||||
@@ -65,7 +65,7 @@ class KnowledgeMetadataImportTests(TestCase):
|
||||
knowledge = Knowledge.objects.get(title="Acme support")
|
||||
self.assertEqual(knowledge.category_id, self.app.id)
|
||||
|
||||
def test_unknown_category_path_fails_per_document(self) -> None:
|
||||
def test_missing_category_is_created_under_existing_parent(self) -> None:
|
||||
response = self._import(
|
||||
[
|
||||
{
|
||||
@@ -78,15 +78,52 @@ class KnowledgeMetadataImportTests(TestCase):
|
||||
)
|
||||
|
||||
payload = response.json()
|
||||
self.assertEqual(payload["created"], 1)
|
||||
self.assertEqual(len(payload["failed"]), 1)
|
||||
self.assertFalse(Knowledge.objects.filter(title="Unknown path").exists())
|
||||
self.assertFalse(
|
||||
KnowledgeCategory.objects.filter(
|
||||
organization=self.organization,
|
||||
name="Missing",
|
||||
).exists()
|
||||
self.assertEqual(payload["created"], 2)
|
||||
self.assertEqual(payload["failed"], [])
|
||||
category = KnowledgeCategory.objects.get(
|
||||
organization=self.organization, parent=self.products, name="Missing"
|
||||
)
|
||||
self.assertEqual(Knowledge.objects.get(title="Unknown path").category_id, category.id)
|
||||
|
||||
def test_new_tree_is_shared_and_repeat_import_does_not_duplicate_categories(self) -> None:
|
||||
documents = [
|
||||
{"title": title, "content": "Text", "categoryPath": [" New root ", "Child", "Leaf"]}
|
||||
for title in ["First", "Second"]
|
||||
]
|
||||
before = KnowledgeCategory.objects.count()
|
||||
self.assertEqual(self._import(documents).json()["created"], 2)
|
||||
self.assertEqual(self._import(documents).json()["unchanged"], 2)
|
||||
self.assertEqual(KnowledgeCategory.objects.count(), before + 3)
|
||||
first = Knowledge.objects.get(title="First")
|
||||
second = Knowledge.objects.get(title="Second")
|
||||
self.assertEqual(first.category_id, second.category_id)
|
||||
self.assertEqual(first.category.parent.parent.name, "New root")
|
||||
|
||||
def test_failed_document_rolls_back_new_categories_and_other_documents_import(self) -> None:
|
||||
response = self._import([
|
||||
{"title": "Invalid", "content": "Text", "description": [],
|
||||
"categoryPath": ["Rollback root", "Child"]},
|
||||
{"title": "Valid sibling", "content": "Text", "categoryPath": ["Kept root"]},
|
||||
])
|
||||
self.assertEqual(response.json()["created"], 1)
|
||||
self.assertEqual(len(response.json()["failed"]), 1)
|
||||
self.assertFalse(KnowledgeCategory.objects.filter(name="Rollback root").exists())
|
||||
self.assertFalse(Knowledge.objects.filter(title="Invalid").exists())
|
||||
|
||||
def test_invalid_path_rolls_back_preceding_levels(self) -> None:
|
||||
for path in [["Invalid root", " "], ["Invalid root", 42], []]:
|
||||
with self.subTest(path=path):
|
||||
response = self._import([{"title": "Invalid path", "content": "Text", "categoryPath": path}])
|
||||
self.assertEqual(len(response.json()["failed"]), 1)
|
||||
self.assertFalse(KnowledgeCategory.objects.filter(name="Invalid root").exists())
|
||||
|
||||
def test_existing_document_moves_to_new_category(self) -> None:
|
||||
self._import([{"title": "Moving", "content": "Text"}])
|
||||
response = self._import([
|
||||
{"title": "Moving", "content": "Text", "categoryPath": ["New destination"]}
|
||||
])
|
||||
self.assertEqual(response.json()["updated"], 1)
|
||||
self.assertEqual(Knowledge.objects.get(title="Moving").category.name, "New destination")
|
||||
|
||||
def test_omitted_metadata_preserves_category_and_agent_links(self) -> None:
|
||||
self._import(
|
||||
@@ -157,6 +194,7 @@ class KnowledgeImportPolicyTests(KnowledgePolicyTestBase):
|
||||
{
|
||||
"title": self.support_only.title,
|
||||
"content": "Attempted overwrite",
|
||||
"categoryPath": ["Forbidden category"],
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -167,3 +205,4 @@ class KnowledgeImportPolicyTests(KnowledgePolicyTestBase):
|
||||
self.assertEqual(response.status_code, 403)
|
||||
self.support_only.refresh_from_db()
|
||||
self.assertEqual(self.support_only.content, "")
|
||||
self.assertFalse(KnowledgeCategory.objects.filter(name="Forbidden category").exists())
|
||||
@@ -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 +13,7 @@ from chatballs.calls.errors import (
|
||||
CallInvalidTransition,
|
||||
CallTokenError,
|
||||
)
|
||||
from chatballs.calls.lifecycle import transition_call
|
||||
from chatballs.calls.lifecycle import finish_call, transition_call
|
||||
from chatballs.calls.metrics import record_call_metric
|
||||
from chatballs.calls.models import (
|
||||
TERMINAL_CALL_STATUSES,
|
||||
@@ -231,6 +231,15 @@ def issue_staff_access_token(*, context: TenantContext, call_session: CallSessio
|
||||
|
||||
|
||||
def cancel_call(*, context: TenantContext, call_session: CallSession) -> CallSession:
|
||||
"""Оператор закончил звонок — из любой фазы, в которой тот ещё жив.
|
||||
|
||||
Кнопка у оператора одна и означает «прекратить»: до ответа клиента это
|
||||
отмена, после — завершение. Раньше здесь был только переход в CANCELLED, и
|
||||
он разрешён лишь из REQUESTED/RINGING: клиент принял звонок, соединение не
|
||||
установилось (частый случай за NAT), оператор жмёт «завершить» — и получает
|
||||
409, а звонок остаётся висеть. Фазу выбирает `finish_call`, тот же код, что
|
||||
и у клиента.
|
||||
"""
|
||||
user = context.actor_user
|
||||
if user is None or context.membership is None:
|
||||
raise CallAccessDenied(t("calls.operator_context_required"))
|
||||
@@ -238,12 +247,9 @@ def cancel_call(*, context: TenantContext, call_session: CallSession) -> CallSes
|
||||
if not call_session.participants.filter(side=ParticipantSide.STAFF, user=user).exists():
|
||||
raise CallConflict(t("calls.not_a_participant"))
|
||||
try:
|
||||
# Повторная отмена идемпотентна: transition_call вернёт звонок без изменений.
|
||||
return transition_call(
|
||||
call_session_id=call_session.id,
|
||||
target_status=CallStatus.CANCELLED,
|
||||
ended_by=CallEndedBy.STAFF,
|
||||
)
|
||||
# Повторный вызов идемпотентен: у завершённого звонка finish_call
|
||||
# возвращает его как есть.
|
||||
return finish_call(call_session_id=call_session.id, side=ParticipantSide.STAFF)
|
||||
except CallInvalidTransition as error:
|
||||
raise CallConflict(t("calls.cannot_cancel")) from error
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ from chatballs.calls.models import (
|
||||
CallStatus,
|
||||
InviteDeliveryStatus,
|
||||
)
|
||||
from chatballs.calls.lifecycle import transition_call
|
||||
from chatballs.calls.services import (
|
||||
decline_call_for_identity,
|
||||
open_call_for_identity,
|
||||
@@ -61,6 +62,22 @@ class CancelCallApiTests(CallTestCase):
|
||||
1,
|
||||
)
|
||||
|
||||
def test_operator_ends_call_the_customer_already_accepted(self) -> None:
|
||||
# Клиент принял звонок, соединение не установилось (частый случай за
|
||||
# NAT). Кнопка оператора одна и обязана закончить звонок, а не упереться
|
||||
# в запрет перехода.
|
||||
created = create_call_request(conversation_id=self.conversation.id, initiator=self.owner)
|
||||
open_call_for_identity(identity=self.identity)
|
||||
transition_call(call_session_id=created.call_session.id, target_status=CallStatus.ACCEPTED)
|
||||
|
||||
response = self.client.post(f"/api/v1/calls/{created.call_session.id}/cancel/")
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
created.call_session.refresh_from_db()
|
||||
self.assertEqual(created.call_session.status, CallStatus.FAILED)
|
||||
self.assertEqual(created.call_session.failure_code, "ABORTED_BEFORE_CONNECT")
|
||||
self.assertEqual(created.call_session.ended_by, CallEndedBy.STAFF)
|
||||
|
||||
def test_non_participant_cannot_cancel(self) -> None:
|
||||
created = create_call_request(conversation_id=self.conversation.id, initiator=self.owner)
|
||||
self.client.force_authenticate(user=self.operator)
|
||||
|
||||
@@ -3,10 +3,11 @@ import hashlib
|
||||
import hmac
|
||||
import time
|
||||
|
||||
from django.test import SimpleTestCase, override_settings
|
||||
from django.test import SimpleTestCase, TestCase, override_settings
|
||||
|
||||
from chatballs.calls.serializers import ice_servers_payload
|
||||
from chatballs.calls.turn import turn_credentials
|
||||
from chatballs.identity.instance_settings import InstanceSettings, turn_config
|
||||
|
||||
SECRET = "coturn-shared-secret"
|
||||
TURN_URLS = ["turn:example.com:3478?transport=udp", "turn:example.com:3478?transport=tcp"]
|
||||
@@ -71,3 +72,56 @@ class IceServersPayloadTests(SimpleTestCase):
|
||||
def test_turn_urls_without_secret_are_not_exposed(self) -> None:
|
||||
# Без секрета выдать рабочие credentials нельзя — TURN не отдаётся вовсе.
|
||||
self.assertEqual(ice_servers_payload(), [])
|
||||
|
||||
|
||||
@override_settings(
|
||||
CHATBALLS_CALL_STUN_URLS=[],
|
||||
CHATBALLS_CALL_TURN_URLS=[],
|
||||
CHATBALLS_CALL_TURN_SECRET=SECRET,
|
||||
CHATBALLS_CALL_TURN_TTL_SECONDS=3600,
|
||||
)
|
||||
class TurnDefaultsTests(TestCase):
|
||||
"""Relay коробки: адреса берутся от адреса установки, без единой настройки."""
|
||||
|
||||
def _set_host(self, host: str) -> None:
|
||||
row = InstanceSettings.load()
|
||||
row.public_host = host
|
||||
row.save()
|
||||
|
||||
def test_addresses_come_from_the_installation_address(self) -> None:
|
||||
self._set_host("crm.example.com")
|
||||
urls, _ttl = turn_config()
|
||||
self.assertEqual(
|
||||
urls,
|
||||
[
|
||||
"turn:crm.example.com:3478?transport=udp",
|
||||
"turn:crm.example.com:3478?transport=tcp",
|
||||
],
|
||||
)
|
||||
|
||||
def test_owner_addresses_win_over_the_defaults(self) -> None:
|
||||
row = InstanceSettings.load()
|
||||
row.public_host = "crm.example.com"
|
||||
row.turn_urls = "turns:turn.example.net:5349?transport=tcp"
|
||||
row.save()
|
||||
urls, _ttl = turn_config()
|
||||
self.assertEqual(urls, ["turns:turn.example.net:5349?transport=tcp"])
|
||||
|
||||
def test_ice_payload_offers_stun_and_turn_of_the_box(self) -> None:
|
||||
self._set_host("crm.example.com")
|
||||
servers = ice_servers_payload()
|
||||
self.assertEqual(servers[0], {"urls": ["stun:crm.example.com:3478"]})
|
||||
self.assertEqual(
|
||||
servers[1]["urls"],
|
||||
[
|
||||
"turn:crm.example.com:3478?transport=udp",
|
||||
"turn:crm.example.com:3478?transport=tcp",
|
||||
],
|
||||
)
|
||||
self.assertEqual(
|
||||
servers[1]["credential"], _expected_credential(servers[1]["username"])
|
||||
)
|
||||
|
||||
def test_without_the_installation_address_there_is_nothing_to_offer(self) -> None:
|
||||
self._set_host("")
|
||||
self.assertEqual(ice_servers_payload(), [])
|
||||
@@ -26,6 +26,7 @@ from chatballs.conversations.models import (
|
||||
Conversation,
|
||||
LifecycleState,
|
||||
)
|
||||
from chatballs.conversations.contact_avatars import contact_avatar_url_in
|
||||
from chatballs.i18n import t
|
||||
from chatballs.identity.audit_catalog import (
|
||||
audit_action_label,
|
||||
@@ -161,7 +162,7 @@ def client_row(contact: Contact) -> dict:
|
||||
# на другом языке это перестало бы работать.
|
||||
"isGuest": not contact.name,
|
||||
"phone": contact.phone,
|
||||
"avatarUrl": contact.avatar_url,
|
||||
"avatarUrl": contact_avatar_url_in(contact, contact.organization_id),
|
||||
"email": next(
|
||||
(
|
||||
identity.external_user_id
|
||||
@@ -301,7 +302,7 @@ def client_detail(organization_id: int, contact_id: int) -> dict:
|
||||
# на другом языке это перестало бы работать.
|
||||
"isGuest": not contact.name,
|
||||
"phone": contact.phone,
|
||||
"avatarUrl": contact.avatar_url,
|
||||
"avatarUrl": contact_avatar_url_in(contact, contact.organization_id),
|
||||
# Поля карточки из чата (описание, компания, город).
|
||||
"description": contact.description,
|
||||
"company": contact.company,
|
||||
@@ -370,7 +371,7 @@ def _duplicate_candidate(organization_id: int, contact: Contact) -> dict | None:
|
||||
"cid": f"CUS-{other.id}",
|
||||
"name": other.name or t("conversations.guest"),
|
||||
"isGuest": not other.name,
|
||||
"avatarUrl": other.avatar_url,
|
||||
"avatarUrl": contact_avatar_url_in(other, other.organization_id),
|
||||
"dialogs": other.conversations.count(),
|
||||
"sources": sorted({identity.connection.provider for identity in identities}),
|
||||
"phone": other.phone,
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
"""Фото контакта: скачиваем у провайдера и отдаём со своего адреса.
|
||||
|
||||
Рабочее место живёт под `Content-Security-Policy: img-src 'self'`, поэтому
|
||||
ссылка на CDN мессенджера до экрана не доезжает — оператор видит инициалы
|
||||
вместо лица. Значит, фото должно лежать у нас и отдаваться тенантным
|
||||
эндпоинтом, как фото сотрудника (`identity.avatars`).
|
||||
|
||||
Источник фото запоминается в `Contact.avatar_source`: у MAX это адрес из
|
||||
профиля отправителя, у Telegram — идентификатор файла фотографии. Пока
|
||||
источник тот же, повторно ничего не качается. Строка `tg:none` означает «у
|
||||
человека в Telegram фото нет»: без неё каждое его сообщение стоило бы лишнего
|
||||
запроса к API.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import logging
|
||||
|
||||
from django.core.files.base import ContentFile
|
||||
|
||||
from chatballs.conversations.models import Contact
|
||||
from chatballs.identity.avatars import image_type, organization_public_id
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
MAX_AVATAR_BYTES = 2 * 1024 * 1024
|
||||
# Проверено и фото нет: помним, чтобы не спрашивать провайдера снова.
|
||||
NO_AVATAR = "none"
|
||||
|
||||
|
||||
def contact_avatar_url_in(contact: Contact | None, organization_id: int) -> str | None:
|
||||
"""Ссылка на фото контакта для рабочего места; None — фото нет."""
|
||||
if contact is None:
|
||||
return None
|
||||
if contact.avatar:
|
||||
version = hashlib.sha1(contact.avatar.name.encode("utf-8")).hexdigest()[:8]
|
||||
public_id = organization_public_id(organization_id)
|
||||
return f"/api/v1/organizations/{public_id}/conversations/clients/{contact.id}/avatar/?v={version}"
|
||||
# Демо-набор и старые записи держат ссылку на наш же адрес — она рабочая.
|
||||
return contact.avatar_url or None
|
||||
|
||||
|
||||
def store_contact_avatar(contact: Contact, *, content: bytes, source: str) -> bool:
|
||||
"""Сохранить скачанное фото. False — это не картинка или она слишком велика."""
|
||||
if not content or len(content) > MAX_AVATAR_BYTES:
|
||||
return False
|
||||
detected = image_type(content)
|
||||
if detected is None:
|
||||
return False
|
||||
content_type, suffix = detected
|
||||
if contact.avatar:
|
||||
contact.avatar.delete(save=False)
|
||||
contact.avatar.save(f"avatar{suffix}", ContentFile(content), save=False)
|
||||
contact.avatar_content_type = content_type
|
||||
contact.avatar_source = source[:512]
|
||||
contact.save(update_fields=["avatar", "avatar_content_type", "avatar_source"])
|
||||
return True
|
||||
|
||||
|
||||
def _checked_marker(source: str) -> str:
|
||||
"""«Фото по этому источнику спрашивали, его нет» — чтобы не спрашивать снова."""
|
||||
return f"{NO_AVATAR}:{source}"[:512]
|
||||
|
||||
|
||||
def refresh_contact_avatar(integration, inbound, contact: Contact) -> None:
|
||||
"""Подтянуть фото отправителя, если провайдер его отдаёт и оно новое.
|
||||
|
||||
Сбой скачивания не мешает сообщению: фото — украшение карточки, а не её
|
||||
содержание.
|
||||
"""
|
||||
from chatballs.conversations import transports
|
||||
|
||||
try:
|
||||
source = transports.avatar_source(integration, inbound)
|
||||
if not source or contact.avatar_source in (source, _checked_marker(source)):
|
||||
return
|
||||
fetched = transports.download_avatar(integration, inbound)
|
||||
if fetched is None:
|
||||
contact.avatar_source = _checked_marker(source)
|
||||
contact.save(update_fields=["avatar_source"])
|
||||
return
|
||||
content, source_key = fetched
|
||||
if not store_contact_avatar(contact, content=content, source=source_key):
|
||||
logger.info("Contact %s avatar from %s is not an image", contact.id, source_key)
|
||||
except Exception as error: # noqa: BLE001 - провайдер/сеть, деградация мягкая
|
||||
logger.info("Contact %s avatar download failed: %s", contact.id, error)
|
||||
@@ -17,6 +17,7 @@ from chatballs.ai.provider.base import ProviderError
|
||||
from chatballs.ai.runtime import HANDOFF_TOKEN
|
||||
from chatballs.channels.runtime import run_channel_turn
|
||||
from chatballs.conversations import transports
|
||||
from chatballs.conversations.contact_avatars import refresh_contact_avatar
|
||||
from chatballs.conversations.models import (
|
||||
ConnectionIdentity,
|
||||
Contact,
|
||||
@@ -177,13 +178,27 @@ def ingest_inbound(integration, inbound: InboundMessage) -> None:
|
||||
context = TenantContext.for_resource(channel.organization)
|
||||
agent = getattr(channel, "ai_agent", None)
|
||||
ai_available = bool(agent and agent.is_active)
|
||||
if not ai_available:
|
||||
# Частая причина «диалог сразу ждёт оператора»: у канала подключения нет
|
||||
# агента или он не активен. В журнале это должно быть видно одной
|
||||
# строкой, иначе настройку ищут перебором.
|
||||
logger.info(
|
||||
"Channel %s has no active AI agent (agent=%s) — conversation goes to the operator queue",
|
||||
channel.id,
|
||||
getattr(agent, "status", None),
|
||||
)
|
||||
source = f"{integration.provider.lower()}:{integration.id}"
|
||||
if _already_processed(context, source, inbound.external_id, inbound.text):
|
||||
return
|
||||
|
||||
# Явный шаринг контакта: сообщение без текста, но с телефоном.
|
||||
is_contact_share = bool(inbound.phone)
|
||||
is_voice = bool(inbound.voice_file_id or inbound.voice_url or inbound.voice_content)
|
||||
is_voice = bool(
|
||||
inbound.voice_file_id
|
||||
or inbound.voice_url
|
||||
or inbound.voice_content
|
||||
or inbound.voice_unavailable
|
||||
)
|
||||
files = tuple(inbound.files or ())
|
||||
# Файлы без текста: сообщение-контейнер не создаём, каждый файл — своя реплика.
|
||||
files_only = bool(files) and not inbound.text and not is_contact_share and not is_voice
|
||||
@@ -224,11 +239,12 @@ def ingest_inbound(integration, inbound: InboundMessage) -> None:
|
||||
if identity.phone_verified_at is None:
|
||||
identity.phone_verified_at = timezone.now()
|
||||
identity.save(update_fields=["phone_verified_at"])
|
||||
# Аватар обновляем при каждом заходе: провайдер может сменить фото,
|
||||
# а контакт ещё не шарил телефон (is_contact_share=False).
|
||||
# Адрес фото у провайдера храним как было, но показываем оператору не
|
||||
# его: страница под CSP `img-src 'self'` чужую картинку не покажет.
|
||||
if inbound.avatar_url and contact.avatar_url != inbound.avatar_url:
|
||||
contact.avatar_url = inbound.avatar_url
|
||||
contact.save(update_fields=["avatar_url"])
|
||||
refresh_contact_avatar(integration, inbound, contact)
|
||||
|
||||
conversation = (
|
||||
Conversation.objects.filter(channel=channel, contact=contact, lifecycle=LifecycleState.OPEN)
|
||||
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
# Generated by Django 5.2.16 on 2026-09-14 21:32
|
||||
|
||||
import chatballs.conversations.models
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('conversations', '0024_queue_policy_author'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='contact',
|
||||
name='avatar',
|
||||
field=models.FileField(blank=True, default='', max_length=512, upload_to=chatballs.conversations.models.contact_avatar_upload_path),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='contact',
|
||||
name='avatar_content_type',
|
||||
field=models.CharField(blank=True, default='', max_length=64),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='contact',
|
||||
name='avatar_source',
|
||||
field=models.CharField(blank=True, default='', max_length=512),
|
||||
),
|
||||
]
|
||||
@@ -11,6 +11,14 @@ from chatballs.tenancy.models import TenantRelationModel
|
||||
# разделено на независимые оси; перехват оператором — атомарный.
|
||||
|
||||
|
||||
def contact_avatar_upload_path(instance: "Contact", filename: str) -> str:
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
|
||||
suffix = Path(filename).suffix.lower()[:8] or ".jpg"
|
||||
return f"organizations/{instance.organization.public_id}/contacts/{uuid.uuid4().hex}{suffix}"
|
||||
|
||||
|
||||
class Contact(models.Model):
|
||||
organization = models.ForeignKey("identity.Organization", on_delete=models.PROTECT, related_name="contacts")
|
||||
name = models.CharField(max_length=255, blank=True)
|
||||
@@ -22,6 +30,16 @@ class Contact(models.Model):
|
||||
# getUpdates, поэтому для него поле остаётся пустым. Хранится только URL —
|
||||
# само изображение живёт на стороне провайдера.
|
||||
avatar_url = models.URLField(max_length=512, blank=True, default="")
|
||||
# Фото контакта, скачанное у провайдера и лежащее у нас. Внешней ссылкой
|
||||
# обойтись нельзя: страница рабочего места живёт под CSP `img-src 'self'`,
|
||||
# и картинка с чужого домена до экрана не доезжает — оператор видит
|
||||
# инициалы вместо фото. Источник запоминается, чтобы не качать то же самое
|
||||
# на каждое сообщение.
|
||||
avatar = models.FileField(
|
||||
upload_to=contact_avatar_upload_path, max_length=512, blank=True, default=""
|
||||
)
|
||||
avatar_content_type = models.CharField(max_length=64, blank=True, default="")
|
||||
avatar_source = models.CharField(max_length=512, blank=True, default="")
|
||||
# Карточка контакта (дизайн-базлайн v2, решение 5): описание, компания, город —
|
||||
# заполняет оператор.
|
||||
description = models.TextField(blank=True, default="")
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.http import FileResponse
|
||||
from rest_framework.request import Request
|
||||
from rest_framework.response import Response
|
||||
|
||||
@@ -21,6 +22,29 @@ class ConversationStatsView(ConversationViewBase):
|
||||
return Response(sales_overview_stats(request.tenant_context, period))
|
||||
|
||||
|
||||
class ContactAvatarView(ConversationViewBase):
|
||||
"""Фото контакта, скачанное у провайдера.
|
||||
|
||||
Отдаётся со своего адреса: страница рабочего места живёт под CSP
|
||||
`img-src 'self'`, и ссылка на CDN мессенджера до экрана не доезжает.
|
||||
Видно тому же, кто видит диалоги, — фото клиента и есть часть карточки.
|
||||
"""
|
||||
|
||||
def get(self, request: Request, contact_id: int):
|
||||
contact = Contact.objects.filter(
|
||||
organization=self._org(request), id=contact_id
|
||||
).first()
|
||||
if contact is None or not contact.avatar:
|
||||
return Response({"detail": t("sales.client_not_found")}, status=404)
|
||||
response = FileResponse(
|
||||
contact.avatar.open("rb"),
|
||||
content_type=contact.avatar_content_type or "application/octet-stream",
|
||||
filename="avatar",
|
||||
)
|
||||
response["Cache-Control"] = "private, max-age=86400"
|
||||
return response
|
||||
|
||||
|
||||
class ClientsView(ConversationViewBase):
|
||||
required_capability = "customers.view"
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ from chatballs.conversations.models import (
|
||||
MessageKind,
|
||||
)
|
||||
from chatballs.i18n import t
|
||||
from chatballs.conversations.contact_avatars import contact_avatar_url_in
|
||||
from chatballs.identity.avatars import user_avatar_url_in
|
||||
from chatballs.integrations.features import features_payload
|
||||
from chatballs.integrations.models import IntegrationProvider
|
||||
@@ -228,7 +229,9 @@ def conversation_payload(
|
||||
"id": conversation.contact_id,
|
||||
"name": conversation.contact.name,
|
||||
"phone": conversation.contact.phone,
|
||||
"avatarUrl": conversation.contact.avatar_url,
|
||||
"avatarUrl": contact_avatar_url_in(
|
||||
conversation.contact, conversation.organization_id
|
||||
),
|
||||
"description": conversation.contact.description,
|
||||
"company": conversation.contact.company,
|
||||
"city": conversation.contact.city,
|
||||
|
||||
@@ -14,10 +14,15 @@ from chatballs.conversations.models import (
|
||||
SystemEvent,
|
||||
)
|
||||
from chatballs.conversations.queue import QUEUE_FIELDS, enter_queue, leave_queue
|
||||
from chatballs.conversations.realtime import notify_conversation_changed
|
||||
from chatballs.i18n import customer_language, t
|
||||
from chatballs.identity.models import EmployeeRole
|
||||
from chatballs.integrations.models import IntegrationProvider
|
||||
from chatballs.notifications.models import NotificationAudience, NotificationType
|
||||
from chatballs.notifications.models import (
|
||||
Notification,
|
||||
NotificationAudience,
|
||||
NotificationType,
|
||||
)
|
||||
from chatballs.notifications.services import notify
|
||||
from chatballs.tenancy.context import TenantContext
|
||||
|
||||
@@ -463,3 +468,41 @@ def assign_operator(*, context: TenantContext, conversation_id: int, assignee) -
|
||||
dedup_key=f"assign:{conversation.id}:{assignee.pk}",
|
||||
)
|
||||
return conversation
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
def delete_conversation(*, context: TenantContext, conversation: Conversation) -> None:
|
||||
"""Удалить диалог насовсем: переписку, вложения, звонки и оклики о нём.
|
||||
|
||||
Раньше «удалить» означало архив: диалог пропадал из списков, но продолжал
|
||||
жить — в него приходили сообщения из канала, он поднимал уведомления и
|
||||
возвращался клиенту в виджете как ни в чём не бывало. Решение владельца
|
||||
2026-09-14: удалён — значит удалён. Права на это есть только у владельца и
|
||||
администратора (проверяет представление).
|
||||
|
||||
Файлы удаляются явно: `FileField` при удалении строки оставляет их на
|
||||
диске, а «удалён» не должно означать «лежит в media».
|
||||
"""
|
||||
conversation_id = conversation.id
|
||||
organization_id = conversation.organization_id
|
||||
for message in conversation.messages.exclude(audio="", attachment=""):
|
||||
if message.audio:
|
||||
message.audio.delete(save=False)
|
||||
if message.attachment:
|
||||
message.attachment.delete(save=False)
|
||||
# Оклик ведёт в диалог, которого больше нет: и в списке уведомлений, и в
|
||||
# мессенджере сотрудника такая строка — тупик.
|
||||
Notification.objects.filter(
|
||||
organization_id=organization_id,
|
||||
source_type="Conversation",
|
||||
source_id=str(conversation_id),
|
||||
).delete()
|
||||
# Звонки держат диалог внешним ключом PROTECT; история звонка без самого
|
||||
# диалога ничего не значит, поэтому уходит вместе с ним.
|
||||
from chatballs.calls.models import CallSession
|
||||
|
||||
CallSession.objects.filter(conversation_id=conversation_id).delete()
|
||||
conversation.delete()
|
||||
# Открытые рабочие места узнают об этом событием: у того, кто держал диалог
|
||||
# открытым, он должен закрыться, а не висеть мёртвой карточкой.
|
||||
notify_conversation_changed(conversation_id, organization_id=organization_id)
|
||||
@@ -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"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Диалог закрыт",
|
||||
|
||||
@@ -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),
|
||||
},
|
||||
|
||||
@@ -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": [
|
||||
|
||||
@@ -187,6 +187,41 @@ class PublicWebChatWidgetTests(TestCase):
|
||||
conversation.refresh_from_db()
|
||||
self.assertEqual(conversation.control_mode, ControlMode.PAUSED)
|
||||
|
||||
def test_deleted_conversation_resets_widget_thread(self) -> None:
|
||||
# Диалог удалили в рабочем месте: виджет должен начать с чистого листа,
|
||||
# а не показывать клиенту переписку, которой больше нет.
|
||||
from chatballs.conversations.models import Conversation
|
||||
from chatballs.conversations.services import delete_conversation
|
||||
from chatballs.tenancy.context import TenantContext
|
||||
|
||||
widget = create_web_widget(self.channel, name="Виджет")
|
||||
token = self._session(widget.public_key).json()["token"]
|
||||
self.client.post(
|
||||
"/api/v1/webchat/messages/",
|
||||
data=json.dumps({"text": "Здравствуйте"}),
|
||||
content_type="application/json",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
payload = self.client.get(
|
||||
"/api/v1/webchat/messages/?since=0",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
).json()
|
||||
self.assertFalse(payload["reset"])
|
||||
last_id = max(message["id"] for message in payload["messages"])
|
||||
|
||||
conversation = Conversation.objects.get(channel=self.channel)
|
||||
delete_conversation(
|
||||
context=TenantContext.for_resource(self.organization),
|
||||
conversation=conversation,
|
||||
)
|
||||
|
||||
after = self.client.get(
|
||||
f"/api/v1/webchat/messages/?since={last_id}",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
).json()
|
||||
self.assertTrue(after["reset"])
|
||||
self.assertEqual(after["messages"], [])
|
||||
|
||||
def test_widget_origin_policy_is_scoped_per_widget(self) -> None:
|
||||
allowed = create_web_widget(
|
||||
self.channel,
|
||||
|
||||
@@ -1,39 +1,32 @@
|
||||
import { Modal } from "antd";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
|
||||
import { Icon } from "../../shared/icons";
|
||||
import { LoadingState } from "../../shared/ui";
|
||||
import { Button, SearchInput } from "../../shared/ui-controls";
|
||||
import { shortDate } from "../../shared/utils";
|
||||
import { fetchKnowledgeList } from "../ai/knowledge/api";
|
||||
import { listPortalArticles, listSupportPortals } from "../support-portals/api";
|
||||
import { AgentKnowledgeGroup } from "./AgentKnowledgeGroup";
|
||||
import { filterGroups, loadChoiceGroups, pickedIds, pickedInGroup, type ChoiceGroup } from "./agentKnowledgeGroups";
|
||||
import type { AgentCard } from "./model";
|
||||
import { t } from "../../i18n";
|
||||
|
||||
// «Выбрать» в блоке «Знания» карточки агента (кадры G3/G5): один список из
|
||||
// материалов библиотеки и опубликованных статей порталов. Агент отвечает
|
||||
// только по явно выбранным знаниям (ADR-CHATBALLS-0041 §8).
|
||||
|
||||
type Choice = {
|
||||
key: string;
|
||||
kind: "knowledge" | "article";
|
||||
id: number;
|
||||
title: string;
|
||||
icon: "doc" | "globe";
|
||||
meta: string;
|
||||
disabled: boolean;
|
||||
};
|
||||
// «Выбрать» в блоке «Знания» карточки агента (кадры G3/G5): материалы
|
||||
// библиотеки и опубликованные статьи порталов, разложенные по категориям и
|
||||
// порталам. Агент отвечает только по явно выбранным знаниям
|
||||
// (ADR-CHATBALLS-0041 §8).
|
||||
//
|
||||
// Развёрнуты сразу только те группы, где уже что-то выбрано: так видно, что
|
||||
// агент читает сейчас, а остальное дерево не занимает окно целиком.
|
||||
|
||||
export function AgentKnowledgeDialog({ card, onClose, onSave }: {
|
||||
card: AgentCard;
|
||||
onClose: () => void;
|
||||
onSave: (selection: { knowledgeIds: number[]; articleIds: number[] }) => Promise<void>;
|
||||
}) {
|
||||
const [choices, setChoices] = useState<Choice[] | null>(null);
|
||||
const [groups, setGroups] = useState<ChoiceGroup[] | null>(null);
|
||||
const [failed, setFailed] = useState(false);
|
||||
const [search, setSearch] = useState("");
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [closed, setClosed] = useState<Set<string>>(() => new Set());
|
||||
const [picked, setPicked] = useState<Set<string>>(
|
||||
() => new Set([
|
||||
...card.knowledge.map((item) => `k${item.id}`),
|
||||
@@ -43,67 +36,38 @@ export function AgentKnowledgeDialog({ card, onClose, onSave }: {
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
async function load() {
|
||||
try {
|
||||
const library = await fetchKnowledgeList();
|
||||
const portals = await listSupportPortals();
|
||||
const articleLists = await Promise.all(portals.items.map((portal) => listPortalArticles(portal.id)));
|
||||
loadChoiceGroups()
|
||||
.then((loaded) => {
|
||||
if (cancelled) return;
|
||||
const articles: Choice[] = portals.items.flatMap((portal, index) => articleLists[index].items
|
||||
.filter((article) => article.status === "PUBLISHED")
|
||||
.map((article) => ({
|
||||
key: `a${article.id}`,
|
||||
kind: "article" as const,
|
||||
id: article.id,
|
||||
title: article.publishedRevision?.title ?? article.slug,
|
||||
icon: "globe" as const,
|
||||
meta: t("ai.portal_named", { name: portal.name }),
|
||||
disabled: false,
|
||||
})));
|
||||
setChoices([
|
||||
...library.items.map((item): Choice => ({
|
||||
key: `k${item.id}`,
|
||||
kind: "knowledge",
|
||||
id: item.id,
|
||||
title: item.title,
|
||||
icon: "doc",
|
||||
meta: item.isEnabled ? t("ai.updated_on", { date: shortDate(item.updatedAt) }) : t("common.off"),
|
||||
disabled: false,
|
||||
})),
|
||||
...articles,
|
||||
]);
|
||||
} catch {
|
||||
setGroups(loaded);
|
||||
setClosed(new Set(loaded.filter((group) => pickedInGroup(group, picked) === 0).map((group) => group.key)));
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setFailed(true);
|
||||
}
|
||||
}
|
||||
void load();
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
// Набор выбранного на момент загрузки берётся из карточки и до ответа
|
||||
// сервера не меняется — перезапускать загрузку на каждый клик незачем.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const visible = useMemo(() => {
|
||||
const needle = search.trim().toLowerCase();
|
||||
if (!needle || choices === null) return choices ?? [];
|
||||
return choices.filter((choice) => choice.title.toLowerCase().includes(needle));
|
||||
}, [choices, search]);
|
||||
const visible = useMemo(() => filterGroups(groups ?? [], search), [groups, search]);
|
||||
const searching = Boolean(search.trim());
|
||||
|
||||
function toggle(key: string) {
|
||||
function toggle(keys: string[], pick: boolean) {
|
||||
setPicked((current) => {
|
||||
const next = new Set(current);
|
||||
if (next.has(key)) next.delete(key);
|
||||
else next.add(key);
|
||||
keys.forEach((key) => (pick ? next.add(key) : next.delete(key)));
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
if (choices === null) return;
|
||||
if (groups === null) return;
|
||||
setSaving(true);
|
||||
setError("");
|
||||
try {
|
||||
await onSave({
|
||||
knowledgeIds: choices.filter((choice) => choice.kind === "knowledge" && picked.has(choice.key)).map((choice) => choice.id),
|
||||
articleIds: choices.filter((choice) => choice.kind === "article" && picked.has(choice.key)).map((choice) => choice.id),
|
||||
});
|
||||
await onSave(pickedIds(groups, picked));
|
||||
onClose();
|
||||
} catch (caught) {
|
||||
setError(caught instanceof Error ? caught.message : t("ai.could_not_save_selection"));
|
||||
@@ -112,28 +76,39 @@ export function AgentKnowledgeDialog({ card, onClose, onSave }: {
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal className="agent-knowledge-modal" open width={560} title={t("ai.select_knowledge")} onCancel={onClose} footer={null} destroyOnClose>
|
||||
<Modal className="agent-knowledge-modal" open width={620} title={t("ai.select_knowledge")} onCancel={onClose} footer={null} destroyOnClose>
|
||||
<p className="agent-create-lead">{t("ai.agent_answers_only_from_selected")}</p>
|
||||
<SearchInput className="agent-knowledge-search" placeholder={t("ai.search_by_title")} value={search} onChange={setSearch} />
|
||||
{choices === null && !failed && <LoadingState />}
|
||||
{groups === null && !failed && <LoadingState />}
|
||||
{failed && <div className="agent-form-error">{t("ai.could_not_load_material_list")}</div>}
|
||||
{choices !== null && (
|
||||
<div className="agent-knowledge-choices">
|
||||
{visible.length === 0 && <p className="agent-knowledge-nothing">{t("common.nothing_found")}</p>}
|
||||
{visible.map((choice) => (
|
||||
<label className={`agent-knowledge-choice ${picked.has(choice.key) ? "is-picked" : ""}`} key={choice.key}>
|
||||
<input type="checkbox" checked={picked.has(choice.key)} onChange={() => toggle(choice.key)} />
|
||||
<Icon name={choice.icon} size={15} strokeWidth={1.9} />
|
||||
<strong>{choice.title}</strong>
|
||||
<small>{choice.meta}</small>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
{groups !== null && (
|
||||
<>
|
||||
<div className="agent-knowledge-summary">{t("common.selected_count", { count: picked.size })}</div>
|
||||
<div className="agent-knowledge-choices">
|
||||
{visible.length === 0 && <p className="agent-knowledge-nothing">{t("common.nothing_found")}</p>}
|
||||
{visible.map((group) => (
|
||||
<AgentKnowledgeGroup
|
||||
group={group}
|
||||
key={group.key}
|
||||
open={searching || !closed.has(group.key)}
|
||||
picked={picked}
|
||||
onToggleAll={toggle}
|
||||
onToggleChoice={(key) => toggle([key], !picked.has(key))}
|
||||
onToggleOpen={() => setClosed((current) => {
|
||||
const next = new Set(current);
|
||||
if (next.has(group.key)) next.delete(group.key);
|
||||
else next.add(group.key);
|
||||
return next;
|
||||
})}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{error && <div className="agent-form-error">{error}</div>}
|
||||
<div className="agent-create-actions">
|
||||
<Button variant="secondary" onClick={onClose}>{t("common.cancel")}</Button>
|
||||
<Button variant="primary" disabled={choices === null || saving} onClick={() => void submit()}>
|
||||
<Button variant="primary" disabled={groups === null || saving} onClick={() => void submit()}>
|
||||
{saving ? t("ai.saving") : t("common.save")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import { Icon } from "../../shared/icons";
|
||||
import { pickedInGroup, type ChoiceGroup } from "./agentKnowledgeGroups";
|
||||
import { t } from "../../i18n";
|
||||
|
||||
// Одна категория (или портал) в диалоге «Знания» карточки агента: заголовок со
|
||||
// счётчиком выбранного, «выбрать все» и список материалов.
|
||||
|
||||
export function AgentKnowledgeGroup({ group, picked, open, onToggleOpen, onToggleChoice, onToggleAll }: {
|
||||
group: ChoiceGroup;
|
||||
picked: ReadonlySet<string>;
|
||||
open: boolean;
|
||||
onToggleOpen: () => void;
|
||||
onToggleChoice: (key: string) => void;
|
||||
onToggleAll: (keys: string[], pick: boolean) => void;
|
||||
}) {
|
||||
const inGroup = pickedInGroup(group, picked);
|
||||
const allPicked = inGroup === group.choices.length;
|
||||
return (
|
||||
<div className="agent-knowledge-group">
|
||||
<div className="agent-knowledge-group-head">
|
||||
<button
|
||||
aria-expanded={open}
|
||||
className={`agent-knowledge-group-toggle${open ? " is-open" : ""}`}
|
||||
type="button"
|
||||
onClick={onToggleOpen}
|
||||
>
|
||||
<Icon name="chevronRight" size={13} strokeWidth={2.2} />
|
||||
<Icon name={group.icon} size={14} strokeWidth={1.8} />
|
||||
<strong>{group.title}</strong>
|
||||
<small>{inGroup > 0 ? `${inGroup} / ${group.choices.length}` : group.choices.length}</small>
|
||||
</button>
|
||||
<button
|
||||
className="link is-muted"
|
||||
type="button"
|
||||
onClick={() => onToggleAll(group.choices.map((choice) => choice.key), !allPicked)}
|
||||
>
|
||||
{allPicked ? t("ai.clear_selection") : t("ai.select_all")}
|
||||
</button>
|
||||
</div>
|
||||
{open && group.choices.map((choice) => (
|
||||
<label className={`agent-knowledge-choice ${picked.has(choice.key) ? "is-picked" : ""}`} key={choice.key}>
|
||||
<input type="checkbox" checked={picked.has(choice.key)} onChange={() => onToggleChoice(choice.key)} />
|
||||
<strong>{choice.title}</strong>
|
||||
<small>{choice.meta}</small>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import { allPages } from "../../shared/allPages";
|
||||
import { shortDate } from "../../shared/utils";
|
||||
import { fetchAllKnowledge, fetchKnowledgeCategories } from "../ai/knowledge/api";
|
||||
import { categoryRows } from "../ai/knowledge/knowledgeLibraryModel";
|
||||
import { knowledgeCategoryPath } from "../ai/knowledge/knowledgeTree";
|
||||
import { listPortalArticles, listSupportPortals } from "../support-portals/api";
|
||||
import { t } from "../../i18n";
|
||||
|
||||
// Что можно выдать агенту: материалы библиотеки и опубликованные статьи
|
||||
// порталов. Набор разложен по категориям библиотеки и по порталам — сплошным
|
||||
// списком в организации с сотней материалов ничего не найти.
|
||||
|
||||
export type KnowledgeChoice = {
|
||||
key: string;
|
||||
kind: "knowledge" | "article";
|
||||
id: number;
|
||||
title: string;
|
||||
meta: string;
|
||||
};
|
||||
|
||||
export type ChoiceGroup = {
|
||||
key: string;
|
||||
title: string;
|
||||
icon: "folder" | "globe";
|
||||
choices: KnowledgeChoice[];
|
||||
};
|
||||
|
||||
/** Все материалы и статьи, разложенные по категориям и порталам. */
|
||||
export async function loadChoiceGroups(): Promise<ChoiceGroup[]> {
|
||||
const [categories, items] = await Promise.all([fetchKnowledgeCategories(), fetchAllKnowledge()]);
|
||||
// Порталы и статьи тоже приходят страницами: диалогу нужен весь набор,
|
||||
// иначе статья со второй страницы портала агенту недоступна.
|
||||
const portals = await allPages((page) => listSupportPortals(undefined, page));
|
||||
const articleLists = await Promise.all(
|
||||
portals.map((portal) => allPages((page) => listPortalArticles(portal.id, undefined, page))),
|
||||
);
|
||||
|
||||
const byCategory = new Map<number, KnowledgeChoice[]>();
|
||||
items.forEach((item) => {
|
||||
const choices = byCategory.get(item.category.id) ?? [];
|
||||
choices.push({
|
||||
key: `k${item.id}`,
|
||||
kind: "knowledge",
|
||||
id: item.id,
|
||||
title: item.title,
|
||||
meta: item.isEnabled ? t("ai.updated_on", { date: shortDate(item.updatedAt) }) : t("common.off"),
|
||||
});
|
||||
byCategory.set(item.category.id, choices);
|
||||
});
|
||||
|
||||
// Порядок категорий — тот же, что в дереве библиотеки; название группы —
|
||||
// полный путь, иначе две «Общие» в разных ветках не различить.
|
||||
const groups: ChoiceGroup[] = categoryRows(categories.items)
|
||||
.filter(({ category }) => (byCategory.get(category.id) ?? []).length > 0)
|
||||
.map(({ category }) => ({
|
||||
key: `c${category.id}`,
|
||||
title: knowledgeCategoryPath(categories.items, category.id) || category.name,
|
||||
icon: "folder" as const,
|
||||
choices: byCategory.get(category.id) ?? [],
|
||||
}));
|
||||
|
||||
// Категория материала могла не приехать в справочник (её удалили из-под
|
||||
// открытой карточки) — такие материалы всё равно должны быть видны.
|
||||
const shown = new Set(groups.flatMap((group) => group.choices.map((choice) => choice.key)));
|
||||
const orphans = items
|
||||
.filter((item) => !shown.has(`k${item.id}`))
|
||||
.map((item): KnowledgeChoice => ({
|
||||
key: `k${item.id}`,
|
||||
kind: "knowledge",
|
||||
id: item.id,
|
||||
title: item.title,
|
||||
meta: item.category.name,
|
||||
}));
|
||||
if (orphans.length > 0) {
|
||||
groups.push({ key: "c0", title: t("ai.other_knowledge"), icon: "folder", choices: orphans });
|
||||
}
|
||||
|
||||
portals.forEach((portal, index) => {
|
||||
const choices = articleLists[index]
|
||||
.filter((article) => article.status === "PUBLISHED")
|
||||
.map((article): KnowledgeChoice => ({
|
||||
key: `a${article.id}`,
|
||||
kind: "article",
|
||||
id: article.id,
|
||||
title: article.publishedRevision?.title ?? article.slug,
|
||||
meta: t("ai.portal_named", { name: portal.name }),
|
||||
}));
|
||||
if (choices.length > 0) {
|
||||
groups.push({ key: `p${portal.id}`, title: portal.name, icon: "globe", choices });
|
||||
}
|
||||
});
|
||||
|
||||
return groups;
|
||||
}
|
||||
|
||||
/** Группы с материалами, подходящими под запрос; пустые группы уходят. */
|
||||
export function filterGroups(groups: ChoiceGroup[], search: string): ChoiceGroup[] {
|
||||
const needle = search.trim().toLowerCase();
|
||||
if (!needle) return groups;
|
||||
return groups
|
||||
.map((group) => ({ ...group, choices: group.choices.filter((choice) => choice.title.toLowerCase().includes(needle)) }))
|
||||
.filter((group) => group.choices.length > 0);
|
||||
}
|
||||
|
||||
export function pickedInGroup(group: ChoiceGroup, picked: ReadonlySet<string>): number {
|
||||
return group.choices.filter((choice) => picked.has(choice.key)).length;
|
||||
}
|
||||
|
||||
/** Идентификаторы выбранного по видам — то, что уходит на сервер. */
|
||||
export function pickedIds(groups: ChoiceGroup[], picked: ReadonlySet<string>) {
|
||||
const chosen = groups.flatMap((group) => group.choices).filter((choice) => picked.has(choice.key));
|
||||
return {
|
||||
knowledgeIds: chosen.filter((choice) => choice.kind === "knowledge").map((choice) => choice.id),
|
||||
articleIds: chosen.filter((choice) => choice.kind === "article").map((choice) => choice.id),
|
||||
};
|
||||
}
|
||||
@@ -391,13 +391,86 @@
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.agent-knowledge-summary {
|
||||
margin: 0 0 6px;
|
||||
color: var(--n-4);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.agent-knowledge-choices {
|
||||
display: flex;
|
||||
max-height: 320px;
|
||||
max-height: 380px;
|
||||
flex-direction: column;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* Группа: категория библиотеки или портал. */
|
||||
.agent-knowledge-group + .agent-knowledge-group {
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.agent-knowledge-group-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
height: 34px;
|
||||
padding-right: 2px;
|
||||
border-top: 1px solid var(--n-9);
|
||||
}
|
||||
|
||||
.agent-knowledge-group-toggle {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
height: 30px;
|
||||
padding: 0 6px 0 2px;
|
||||
border: 0;
|
||||
border-radius: 7px;
|
||||
background: transparent;
|
||||
color: var(--n-4);
|
||||
font-family: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.agent-knowledge-group-toggle:hover {
|
||||
background: var(--n-9);
|
||||
}
|
||||
|
||||
.agent-knowledge-group-toggle > svg:first-child {
|
||||
flex: none;
|
||||
transition: transform 140ms ease;
|
||||
}
|
||||
|
||||
.agent-knowledge-group-toggle.is-open > svg:first-child {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.agent-knowledge-group-toggle strong {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
color: var(--n-1);
|
||||
font-size: 12.5px;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.agent-knowledge-group-toggle small {
|
||||
flex: none;
|
||||
color: var(--n-5);
|
||||
font-size: 11.5px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.agent-knowledge-group .agent-knowledge-choice {
|
||||
padding-left: 22px;
|
||||
}
|
||||
|
||||
.agent-knowledge-choice {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -16,79 +16,8 @@ import {
|
||||
} from "./model";
|
||||
import { t, tn } from "../../../i18n";
|
||||
import { readableSize } from "../../../shared/utils";
|
||||
|
||||
// Импорт YAML (дизайн-базлайн v2, кадр KB8): файл разбирается до применения —
|
||||
// видно, что создастся, что обновится и где ошибка пути. Импорт не создаёт
|
||||
// категории неявно, поэтому несуществующий categoryPath отклоняется отдельно.
|
||||
|
||||
type ImportAction = "create" | "update" | "error";
|
||||
|
||||
type ImportRow = {
|
||||
document: ParsedKnowledgeYaml["documents"][number];
|
||||
action: ImportAction;
|
||||
path: string;
|
||||
note: string;
|
||||
};
|
||||
|
||||
const ACTION_LABEL: Record<ImportAction, string> = {
|
||||
create: t("ai.create"),
|
||||
update: t("ai.update"),
|
||||
error: t("ai.error"),
|
||||
};
|
||||
|
||||
/** Разрешает путь категории в дерево организации: путь ищется по уровням. */
|
||||
function resolvePath(categories: KnowledgeCategory[], path: string[]): { ok: boolean; missing: string } {
|
||||
let parentId: number | null = null;
|
||||
for (const name of path) {
|
||||
const found: KnowledgeCategory | undefined = categories.find(
|
||||
(category) => category.parentId === parentId && category.name === name,
|
||||
);
|
||||
if (!found) return { ok: false, missing: name };
|
||||
parentId = found.id;
|
||||
}
|
||||
return { ok: true, missing: "" };
|
||||
}
|
||||
|
||||
function planRows(
|
||||
parsed: ParsedKnowledgeYaml,
|
||||
categories: KnowledgeCategory[],
|
||||
items: KnowledgeItem[],
|
||||
): ImportRow[] {
|
||||
const byTitle = new Map(items.map((item) => [item.title, item]));
|
||||
return parsed.documents.map((document) => {
|
||||
const existing = byTitle.get(document.title) ?? null;
|
||||
if (document.categoryPath) {
|
||||
const { ok, missing } = resolvePath(categories, document.categoryPath);
|
||||
if (!ok) {
|
||||
return {
|
||||
document,
|
||||
action: "error",
|
||||
path: document.categoryPath.join(" / "),
|
||||
note: t("ai.missing_category_path", { name: missing }),
|
||||
};
|
||||
}
|
||||
}
|
||||
const path = document.categoryPath ? document.categoryPath.join(" / ") : t("ai.not_given");
|
||||
if (!existing) {
|
||||
return {
|
||||
document,
|
||||
action: "create",
|
||||
path,
|
||||
note: document.categoryPath ? "" : t("ai.will_land_no_category"),
|
||||
};
|
||||
}
|
||||
const samePlace = document.categoryPath
|
||||
&& document.categoryPath[document.categoryPath.length - 1] === existing.category.name;
|
||||
return {
|
||||
document,
|
||||
action: "update",
|
||||
path,
|
||||
note: samePlace
|
||||
? t("ai.category_file_matches_current_one")
|
||||
: t("ai.title_matched_text_will_replaced"),
|
||||
};
|
||||
});
|
||||
}
|
||||
import { planRows } from "./knowledgeImportPlan";
|
||||
import { KnowledgeImportPreview } from "./KnowledgeImportPreview";
|
||||
|
||||
export function KnowledgeImportPage({
|
||||
canManage,
|
||||
@@ -218,44 +147,11 @@ export function KnowledgeImportPage({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{parsed && (
|
||||
<div className="knowledge-import-plan">
|
||||
<div className="knowledge-import-plan-head">
|
||||
<strong>{t("ai.what_will_happen")}</strong>
|
||||
<span>
|
||||
{counts.create > 0 && <small className="is-create">{t("ai.count_create", { count: counts.create })}</small>}
|
||||
{counts.update > 0 && <small className="is-update">{t("ai.count_update", { count: counts.update })}</small>}
|
||||
{counts.error > 0 && <small className="is-error">{t("ai.count_error", { count: counts.error })}</small>}
|
||||
</span>
|
||||
</div>
|
||||
<table className="knowledge-import-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t("ai.document")}</th>
|
||||
<th>CATEGORYPATH</th>
|
||||
<th>{t("ai.action")}</th>
|
||||
<th>{t("ai.note")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((row, index) => (
|
||||
<tr className={row.action === "error" ? "is-error" : ""} key={`${row.document.title}-${index}`}>
|
||||
<td><code>{row.document.title}</code></td>
|
||||
<td className="knowledge-import-path">{row.path}</td>
|
||||
<td><span className={`knowledge-import-action is-${row.action}`}>{ACTION_LABEL[row.action]}</span></td>
|
||||
<td className="knowledge-import-note">{row.note}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
{parsed && <KnowledgeImportPreview rows={rows} counts={counts} />}
|
||||
|
||||
{parsed && (
|
||||
<div className="knowledge-import-actions">
|
||||
<span>{t("ai.import_never_creates_categories_implicitly")}<code>categoryPath</code>{" "}
|
||||
{t("ai.import_path_tail")}
|
||||
</span>
|
||||
<span>{t("ai.import_category_policy")}</span>
|
||||
<Button variant="secondary" disabled={busy} onClick={() => setRoute("knowledge")}>{t("common.cancel")}</Button>
|
||||
<Button variant="primary" disabled={busy || !canManage || importable.length === 0} onClick={() => void submit()}>
|
||||
{t("ai.import_documents", { count: tn("plural.documents", importable.length) })}
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { t } from "../../../i18n";
|
||||
import { ACTION_LABEL, type ImportAction, type ImportRow } from "./knowledgeImportPlan";
|
||||
|
||||
export function KnowledgeImportPreview({ rows, counts }: {
|
||||
rows: ImportRow[];
|
||||
counts: Record<ImportAction, number>;
|
||||
}) {
|
||||
return (
|
||||
<div className="knowledge-import-plan">
|
||||
<div className="knowledge-import-plan-head">
|
||||
<strong>{t("ai.what_will_happen")}</strong>
|
||||
<span>
|
||||
{counts.create > 0 && <small className="is-create">{t("ai.count_create", { count: counts.create })}</small>}
|
||||
{counts.update > 0 && <small className="is-update">{t("ai.count_update", { count: counts.update })}</small>}
|
||||
{counts.error > 0 && <small className="is-error">{t("ai.count_error", { count: counts.error })}</small>}
|
||||
</span>
|
||||
</div>
|
||||
<table className="knowledge-import-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t("ai.document")}</th>
|
||||
<th>CATEGORYPATH</th>
|
||||
<th>{t("ai.action")}</th>
|
||||
<th>{t("ai.note")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((row, index) => (
|
||||
<tr className={row.action === "error" ? "is-error" : ""} key={`${row.document.title}-${index}`}>
|
||||
<td><code>{row.document.title}</code></td>
|
||||
<td className="knowledge-import-path">{row.path}</td>
|
||||
<td><span className={`knowledge-import-action is-${row.action}`}>{ACTION_LABEL[row.action]}</span></td>
|
||||
<td className="knowledge-import-note">{row.note}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Dropdown } from "antd";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useState } from "react";
|
||||
|
||||
import { hasCapability } from "../../../auth/access";
|
||||
import { Pagination } from "../../../shared/Pagination";
|
||||
@@ -14,11 +14,10 @@ import { KnowledgeBulkBar } from "./KnowledgeBulkBar";
|
||||
import { KnowledgeCategoryDialog } from "./KnowledgeCategoryDialog";
|
||||
import {
|
||||
answerStateLabel,
|
||||
categoryBranch,
|
||||
categoryRows,
|
||||
knowledgeUpdatedAt,
|
||||
libraryTotals,
|
||||
} from "./knowledgeLibraryModel";
|
||||
import { KnowledgeSections } from "./KnowledgeSections";
|
||||
import { knowledgeCategoryPath } from "./knowledgeTree";
|
||||
import {
|
||||
bulkMoveKnowledge,
|
||||
@@ -64,7 +63,6 @@ export function KnowledgeLibraryPage({
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
const rows = useMemo(() => categoryRows(library.categories), [library.categories]);
|
||||
const selectedCategory = library.filters.category;
|
||||
|
||||
const selected = [...library.selectedIds];
|
||||
@@ -166,42 +164,13 @@ export function KnowledgeLibraryPage({
|
||||
{error && <div className="knowledge-form-error">{error}</div>}
|
||||
|
||||
<div className="knowledge-library">
|
||||
<aside className="knowledge-sections">
|
||||
<header>
|
||||
<span>{t("ai.categories")}</span>
|
||||
{canManage && <button type="button" onClick={() => setRoute("knowledgeCategories")}>{t("ai.manage")}</button>}
|
||||
</header>
|
||||
<nav>
|
||||
<button
|
||||
className={`knowledge-section-row${selectedCategory === undefined ? " is-active" : ""}`}
|
||||
style={{ paddingLeft: 9 }}
|
||||
type="button"
|
||||
onClick={() => library.updateFilter("category", undefined)}
|
||||
>
|
||||
<Icon name="folder" size={15} strokeWidth={1.8} />
|
||||
<span>{t("ai.all_knowledge")}</span>
|
||||
<small>{library.categories.filter((item) => item.parentId === null).reduce((total, item) => total + (item.knowledgeCount ?? 0), 0)}</small>
|
||||
</button>
|
||||
{rows.map(({ category, depth, count }) => (
|
||||
<button
|
||||
className={`knowledge-section-row${selectedCategory === category.id ? " is-active" : ""}`}
|
||||
key={category.id}
|
||||
style={{ paddingLeft: 9 + depth * 16 }}
|
||||
type="button"
|
||||
onClick={() => library.updateFilter("category", category.id)}
|
||||
>
|
||||
<span>{category.name}</span>
|
||||
<small>{count}</small>
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
{canManage && (
|
||||
<div className="knowledge-sections-foot">
|
||||
<button type="button" onClick={() => setRoute("knowledgeCategories")}>
|
||||
<Icon name="plus" size={14} strokeWidth={2} />{t("ai.add_category")}</button>
|
||||
</div>
|
||||
)}
|
||||
</aside>
|
||||
<KnowledgeSections
|
||||
canManage={canManage}
|
||||
categories={library.categories}
|
||||
selected={selectedCategory}
|
||||
onManage={() => setRoute("knowledgeCategories")}
|
||||
onSelect={(categoryId) => library.updateFilter("category", categoryId)}
|
||||
/>
|
||||
|
||||
<div className="knowledge-library-main">
|
||||
<div className="knowledge-library-inner">
|
||||
@@ -376,7 +345,9 @@ export function KnowledgeLibraryPage({
|
||||
note={t("ai.library_note", { count: tn("plural.knowledge", library.total), shown: library.items.length })}
|
||||
page={library.page}
|
||||
pageCount={library.pageCount}
|
||||
pageSize={library.pageSize}
|
||||
onPage={library.setPage}
|
||||
onPageSize={library.setPageSize}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
import { useMemo, useState } from "react";
|
||||
|
||||
import { Icon } from "../../../shared/icons";
|
||||
import { t } from "../../../i18n";
|
||||
import { categoryRows, visibleCategoryRows } from "./knowledgeLibraryModel";
|
||||
import type { KnowledgeCategory } from "./model";
|
||||
|
||||
// Левая панель библиотеки знаний (кадр KB1): дерево категорий.
|
||||
//
|
||||
// Ветку с вложенными категориями сворачивают шевроном — у организации с
|
||||
// трёхуровневым деревом список иначе занимает панель целиком. Свёрнутое
|
||||
// помнит браузер: человек возвращается в раздел к тому же виду, что оставил.
|
||||
|
||||
const STORAGE_KEY = "chatballs.ui.knowledge.collapsed";
|
||||
|
||||
function readCollapsed(): Set<number> {
|
||||
try {
|
||||
const raw = window.localStorage.getItem(STORAGE_KEY);
|
||||
const stored = raw ? (JSON.parse(raw) as unknown) : null;
|
||||
return new Set(Array.isArray(stored) ? stored.filter((item): item is number => typeof item === "number") : []);
|
||||
} catch {
|
||||
return new Set();
|
||||
}
|
||||
}
|
||||
|
||||
function rememberCollapsed(collapsed: Set<number>): void {
|
||||
try {
|
||||
window.localStorage.setItem(STORAGE_KEY, JSON.stringify([...collapsed]));
|
||||
} catch {
|
||||
/* без хранилища дерево просто не помнит свёрнутое между заходами */
|
||||
}
|
||||
}
|
||||
|
||||
export function KnowledgeSections({ categories, selected, canManage, onSelect, onManage }: {
|
||||
categories: KnowledgeCategory[];
|
||||
selected: number | undefined;
|
||||
canManage: boolean;
|
||||
onSelect: (categoryId: number | undefined) => void;
|
||||
onManage: () => void;
|
||||
}) {
|
||||
const [collapsed, setCollapsed] = useState<Set<number>>(readCollapsed);
|
||||
const rows = useMemo(() => categoryRows(categories), [categories]);
|
||||
const visible = useMemo(() => visibleCategoryRows(rows, collapsed), [rows, collapsed]);
|
||||
const total = categories
|
||||
.filter((category) => category.parentId === null)
|
||||
.reduce((sum, category) => sum + (category.knowledgeCount ?? 0), 0);
|
||||
|
||||
function toggle(categoryId: number) {
|
||||
setCollapsed((current) => {
|
||||
const next = new Set(current);
|
||||
if (next.has(categoryId)) next.delete(categoryId);
|
||||
else next.add(categoryId);
|
||||
rememberCollapsed(next);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<aside className="knowledge-sections">
|
||||
<header>
|
||||
<span>{t("ai.categories")}</span>
|
||||
{canManage && <button type="button" onClick={onManage}>{t("ai.manage")}</button>}
|
||||
</header>
|
||||
<nav>
|
||||
<div className={`knowledge-section-row${selected === undefined ? " is-active" : ""}`}>
|
||||
<i className="knowledge-section-twist" />
|
||||
<button className="knowledge-section-pick" type="button" onClick={() => onSelect(undefined)}>
|
||||
<Icon name="folder" size={15} strokeWidth={1.8} />
|
||||
<span>{t("ai.all_knowledge")}</span>
|
||||
<small>{total}</small>
|
||||
</button>
|
||||
</div>
|
||||
{visible.map(({ category, depth, count, hasChildren }) => {
|
||||
const open = !collapsed.has(category.id);
|
||||
return (
|
||||
<div
|
||||
className={`knowledge-section-row${selected === category.id ? " is-active" : ""}`}
|
||||
key={category.id}
|
||||
style={{ paddingLeft: depth * 16 }}
|
||||
>
|
||||
{hasChildren ? (
|
||||
<button
|
||||
aria-expanded={open}
|
||||
aria-label={open
|
||||
? t("ai.collapse_category", { name: category.name })
|
||||
: t("ai.expand_category", { name: category.name })}
|
||||
className={`knowledge-section-twist${open ? " is-open" : ""}`}
|
||||
type="button"
|
||||
onClick={() => toggle(category.id)}
|
||||
>
|
||||
<Icon name="chevronRight" size={13} strokeWidth={2.2} />
|
||||
</button>
|
||||
) : (
|
||||
<i className="knowledge-section-twist" />
|
||||
)}
|
||||
<button className="knowledge-section-pick" type="button" onClick={() => onSelect(category.id)}>
|
||||
<span>{category.name}</span>
|
||||
<small>{count}</small>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
{canManage && (
|
||||
<div className="knowledge-sections-foot">
|
||||
<button type="button" onClick={onManage}>
|
||||
<Icon name="plus" size={14} strokeWidth={2} />{t("ai.add_category")}</button>
|
||||
</div>
|
||||
)}
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { allPages } from "../../../shared/allPages";
|
||||
import { api, apiUpload } from "../../../api/client";
|
||||
import type { PagedPayload } from "../../../shared/usePagedResource";
|
||||
import type {
|
||||
@@ -18,21 +19,31 @@ import type {
|
||||
KnowledgeUpdateRequest,
|
||||
} from "./types";
|
||||
|
||||
function knowledgeListPath(filters: KnowledgeListFilters, page?: number): string {
|
||||
function knowledgeListPath(filters: KnowledgeListFilters, page?: number, pageSize?: number): string {
|
||||
const query = new URLSearchParams();
|
||||
if (filters.category !== undefined) query.set("category", String(filters.category));
|
||||
if (filters.isEnabled !== undefined) query.set("isEnabled", String(filters.isEnabled));
|
||||
if (filters.search !== undefined && filters.search !== "") query.set("search", filters.search);
|
||||
for (const agent of filters.agents ?? []) query.append("agent", String(agent));
|
||||
if (page !== undefined) query.set("page", String(page));
|
||||
if (pageSize !== undefined) query.set("pageSize", String(pageSize));
|
||||
const suffix = query.toString();
|
||||
return `/api/v1/ai/knowledge/${suffix ? `?${suffix}` : ""}`;
|
||||
}
|
||||
|
||||
// Библиотека знаний приходит страницей: фильтры и ветка категорий отрабатывают
|
||||
// на сервере (кадр KB1).
|
||||
export function fetchKnowledgeList(filters: KnowledgeListFilters = {}, page = 1) {
|
||||
return api<PagedPayload<KnowledgeItem>>(knowledgeListPath(filters, page));
|
||||
export function fetchKnowledgeList(filters: KnowledgeListFilters = {}, page = 1, pageSize?: number) {
|
||||
return api<PagedPayload<KnowledgeItem>>(knowledgeListPath(filters, page, pageSize));
|
||||
}
|
||||
|
||||
// Потолок страницы на сервере (`api/pagination.py`, MAX_PAGE_SIZE).
|
||||
const FULL_LIST_PAGE_SIZE = 100;
|
||||
|
||||
/** Вся библиотека целиком, страницами по сотне: диалогу выбора знаний агента
|
||||
* нужен весь набор, а не первая страница списка. */
|
||||
export function fetchAllKnowledge(filters: KnowledgeListFilters = {}): Promise<KnowledgeItem[]> {
|
||||
return allPages((page) => fetchKnowledgeList(filters, page, FULL_LIST_PAGE_SIZE));
|
||||
}
|
||||
|
||||
export function fetchKnowledgeItem(id: number) {
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { t } from "../../../i18n";
|
||||
import { planRows } from "./knowledgeImportPlan";
|
||||
import { parseKnowledgeYaml } from "./parseKnowledgeYaml";
|
||||
import type { KnowledgeCategory, KnowledgeItem } from "./types";
|
||||
|
||||
const categories: KnowledgeCategory[] = [
|
||||
{ id: 1, name: "Root", parentId: null, sortOrder: 0, isSystem: false, knowledgeCount: 0 },
|
||||
{ id: 2, name: "Child", parentId: 1, sortOrder: 0, isSystem: false, knowledgeCount: 0 },
|
||||
{ id: 3, name: "Other", parentId: null, sortOrder: 0, isSystem: false, knowledgeCount: 0 },
|
||||
{ id: 4, name: "Child", parentId: 3, sortOrder: 0, isSystem: false, knowledgeCount: 0 },
|
||||
];
|
||||
const existing: KnowledgeItem = {
|
||||
id: 10, title: "Existing", description: "", category: categories[1],
|
||||
isEnabled: true, attachments: [], agentsCount: 0, fragmentsCount: 0,
|
||||
createdAt: "", updatedAt: "",
|
||||
};
|
||||
|
||||
describe("knowledge import preview", () => {
|
||||
it("allows an entirely new category tree from YAML", () => {
|
||||
const parsed = parseKnowledgeYaml("documents:\n - title: New\n content: Text\n categoryPath: [New root, New child]");
|
||||
const [row] = planRows(parsed, [], []);
|
||||
expect(row.action).toBe("create");
|
||||
expect(row.path).toBe("New root / New child");
|
||||
expect(row.note).toBe(t("ai.import_category_creation"));
|
||||
});
|
||||
|
||||
it("allows missing descendants and updates existing documents", () => {
|
||||
const [row] = planRows({ documents: [
|
||||
{ title: existing.title, content: "Text", categoryPath: ["Root", "New child"] },
|
||||
] }, categories, [existing]);
|
||||
expect(row.action).toBe("update");
|
||||
expect(row.note).toBe(t("ai.import_category_creation"));
|
||||
});
|
||||
|
||||
it("compares the full path instead of only the leaf name", () => {
|
||||
const rows = planRows({ documents: [
|
||||
{ title: existing.title, content: "Text", categoryPath: ["Other", "Child"] },
|
||||
{ title: existing.title, content: "Text", categoryPath: ["Root", "Child"] },
|
||||
] }, categories, [existing]);
|
||||
expect(rows[0].note).toBe(t("ai.title_matched_text_will_replaced"));
|
||||
expect(rows[1].note).toBe(t("ai.category_file_matches_current_one"));
|
||||
});
|
||||
|
||||
it("keeps documents without a path importable", () => {
|
||||
const [row] = planRows({ documents: [{ title: "New", content: "Text" }] }, [], []);
|
||||
expect(row.action).toBe("create");
|
||||
expect(row.note).toBe(t("ai.will_land_no_category"));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
import { t } from "../../../i18n";
|
||||
import type { KnowledgeCategory, KnowledgeItem } from "./model";
|
||||
import type { ParsedKnowledgeYaml } from "./parseKnowledgeYaml";
|
||||
|
||||
export type ImportAction = "create" | "update" | "error";
|
||||
|
||||
export type ImportRow = {
|
||||
document: ParsedKnowledgeYaml["documents"][number];
|
||||
action: ImportAction;
|
||||
path: string;
|
||||
note: string;
|
||||
};
|
||||
|
||||
export const ACTION_LABEL: Record<ImportAction, string> = {
|
||||
create: t("ai.create"),
|
||||
update: t("ai.update"),
|
||||
error: t("ai.error"),
|
||||
};
|
||||
|
||||
/** Разрешает путь категории в дерево организации: путь ищется по уровням. */
|
||||
function resolvePath(categories: KnowledgeCategory[], path: string[]): number | null {
|
||||
let parentId: number | null = null;
|
||||
for (const name of path) {
|
||||
const found: KnowledgeCategory | undefined = categories.find(
|
||||
(category) => category.parentId === parentId && category.name === name,
|
||||
);
|
||||
if (!found) return null;
|
||||
parentId = found.id;
|
||||
}
|
||||
return parentId;
|
||||
}
|
||||
|
||||
export function planRows(
|
||||
parsed: ParsedKnowledgeYaml,
|
||||
categories: KnowledgeCategory[],
|
||||
items: KnowledgeItem[],
|
||||
): ImportRow[] {
|
||||
const byTitle = new Map(items.map((item) => [item.title, item]));
|
||||
return parsed.documents.map((document) => {
|
||||
const existing = byTitle.get(document.title) ?? null;
|
||||
const categoryId = document.categoryPath ? resolvePath(categories, document.categoryPath) : null;
|
||||
const categoryNote = document.categoryPath && categoryId === null ? t("ai.import_category_creation") : "";
|
||||
const path = document.categoryPath ? document.categoryPath.join(" / ") : t("ai.not_given");
|
||||
if (!existing) {
|
||||
return {
|
||||
document,
|
||||
action: "create",
|
||||
path,
|
||||
note: document.categoryPath ? categoryNote : t("ai.will_land_no_category"),
|
||||
};
|
||||
}
|
||||
const samePlace = categoryId !== null && categoryId === existing.category.id;
|
||||
return {
|
||||
document,
|
||||
action: "update",
|
||||
path,
|
||||
note: categoryNote || (samePlace
|
||||
? t("ai.category_file_matches_current_one")
|
||||
: t("ai.title_matched_text_will_replaced")),
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -10,6 +10,8 @@ export type CategoryRow = {
|
||||
category: KnowledgeCategory;
|
||||
depth: number;
|
||||
count: number;
|
||||
/** Есть ли вложенные категории: только такую строку можно свернуть. */
|
||||
hasChildren: boolean;
|
||||
};
|
||||
|
||||
/** Категории в порядке дерева с глубиной вложенности (левая панель KB1). */
|
||||
@@ -26,7 +28,12 @@ export function categoryRows(categories: KnowledgeCategory[]): CategoryRow[] {
|
||||
const rows: CategoryRow[] = [];
|
||||
const walk = (parentId: number | null, depth: number) => {
|
||||
(byParent.get(parentId) ?? []).forEach((category) => {
|
||||
rows.push({ category, depth, count: category.knowledgeCount ?? 0 });
|
||||
rows.push({
|
||||
category,
|
||||
depth,
|
||||
count: category.knowledgeCount ?? 0,
|
||||
hasChildren: (byParent.get(category.id) ?? []).length > 0,
|
||||
});
|
||||
walk(category.id, depth + 1);
|
||||
});
|
||||
};
|
||||
@@ -34,6 +41,24 @@ export function categoryRows(categories: KnowledgeCategory[]): CategoryRow[] {
|
||||
return rows;
|
||||
}
|
||||
|
||||
/** Строки дерева без содержимого свёрнутых веток.
|
||||
* Строки идут обходом в глубину, поэтому потомки свёрнутой категории — это
|
||||
* всё, что следует за ней глубже её уровня, до первой строки того же уровня. */
|
||||
export function visibleCategoryRows(
|
||||
rows: CategoryRow[],
|
||||
collapsed: ReadonlySet<number>,
|
||||
): CategoryRow[] {
|
||||
const visible: CategoryRow[] = [];
|
||||
let hiddenBelow: number | null = null;
|
||||
rows.forEach((row) => {
|
||||
if (hiddenBelow !== null && row.depth > hiddenBelow) return;
|
||||
hiddenBelow = null;
|
||||
visible.push(row);
|
||||
if (row.hasChildren && collapsed.has(row.category.id)) hiddenBelow = row.depth;
|
||||
});
|
||||
return visible;
|
||||
}
|
||||
|
||||
/** Все потомки категории вместе с ней — выбор в дереве включает вложенные. */
|
||||
export function categoryBranch(categories: KnowledgeCategory[], categoryId: number): Set<number> {
|
||||
const branch = new Set<number>();
|
||||
|
||||
@@ -8,8 +8,7 @@ export type ParsedKnowledgeYaml = {
|
||||
// Парсит YAML импорта знаний (SPEC-CHATBALLS-0012, плоский формат ADR-CHATBALLS-0023):
|
||||
// { documents: [{ title, description?, content, categoryPath? }] }. Бросает
|
||||
// Error с человекочитаемым сообщением при невалидном YAML или структуре.
|
||||
// Путь категории разбирается, но не проверяется: несуществующий путь — это
|
||||
// замечание в предпросмотре импорта (кадр KB8), а не поломка файла.
|
||||
// Недостающие уровни пути сервер создаёт при применении импорта.
|
||||
export function parseKnowledgeYaml(text: string): ParsedKnowledgeYaml {
|
||||
let data: unknown;
|
||||
try {
|
||||
|
||||
@@ -202,23 +202,16 @@
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* Строка дерева: слева шеврон ветки, дальше — кнопка выбора категории.
|
||||
Кнопка в кнопку вложить нельзя, поэтому строка — контейнер, а фон выделения
|
||||
и наведения принадлежит ей, а не кнопке выбора. */
|
||||
.knowledge-section-row {
|
||||
display: flex;
|
||||
min-height: 32px;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding-top: 0;
|
||||
padding-bottom: 0;
|
||||
padding-right: 9px;
|
||||
border: 0;
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
color: var(--n-2);
|
||||
font-family: inherit;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.knowledge-section-row:hover { background: var(--n-9); }
|
||||
@@ -226,10 +219,55 @@
|
||||
.knowledge-section-row.is-active {
|
||||
background: var(--primary-bg);
|
||||
color: var(--primary-text);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.knowledge-section-row > span {
|
||||
/* У категории без вложенных на месте шеврона пустое место той же ширины:
|
||||
иначе названия соседних строк разъезжались бы по левому краю. */
|
||||
.knowledge-section-twist {
|
||||
display: inline-flex;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
flex: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-left: 3px;
|
||||
border: 0;
|
||||
border-radius: 5px;
|
||||
background: transparent;
|
||||
color: var(--n-5);
|
||||
cursor: pointer;
|
||||
transition: transform 140ms ease;
|
||||
}
|
||||
|
||||
button.knowledge-section-twist:hover {
|
||||
background: var(--n-8);
|
||||
color: var(--n-3);
|
||||
}
|
||||
|
||||
.knowledge-section-twist.is-open { transform: rotate(90deg); }
|
||||
|
||||
.knowledge-section-pick {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
min-height: 32px;
|
||||
flex: 1;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 0 9px 0 6px;
|
||||
border: 0;
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
font-family: inherit;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.knowledge-section-row.is-active .knowledge-section-pick { font-weight: 600; }
|
||||
|
||||
.knowledge-section-pick > span {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
@@ -237,13 +275,13 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.knowledge-section-row small {
|
||||
.knowledge-section-pick small {
|
||||
color: var(--n-5);
|
||||
font-size: 11.5px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.knowledge-section-row.is-active small { color: var(--primary-text); }
|
||||
.knowledge-section-row.is-active .knowledge-section-pick small { color: var(--primary-text); }
|
||||
|
||||
.knowledge-sections-foot {
|
||||
flex: none;
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
|
||||
import { useDebounced } from "../../../shared/useDebounced";
|
||||
import { usePagedResource } from "../../../shared/usePagedResource";
|
||||
import { usePageSize } from "../../../shared/usePageSize";
|
||||
import {
|
||||
fetchKnowledgeCategories,
|
||||
fetchKnowledgeList,
|
||||
@@ -30,6 +31,10 @@ export function useKnowledgeLibrary() {
|
||||
const [categoriesError, setCategoriesError] = useState(false);
|
||||
const [selectedIds, setSelectedIds] = useState<Set<number>>(() => new Set());
|
||||
const settledSearch = useDebounced(filters.search.trim());
|
||||
// Размер страницы — часть набора: при его смене список возвращается на
|
||||
// первую страницу, иначе седьмая страница по двадцать превратилась бы в
|
||||
// седьмую по сто и оказалась бы за концом набора.
|
||||
const { pageSize, setPageSize } = usePageSize("knowledge");
|
||||
|
||||
const reloadCategories = useCallback(async () => {
|
||||
setCategoriesLoading(true);
|
||||
@@ -50,10 +55,14 @@ export function useKnowledgeLibrary() {
|
||||
isEnabled: filters.isEnabled,
|
||||
search: settledSearch,
|
||||
agents: filters.agents,
|
||||
pageSize,
|
||||
}),
|
||||
[filters.agents, filters.category, filters.isEnabled, settledSearch],
|
||||
[filters.agents, filters.category, filters.isEnabled, pageSize, settledSearch],
|
||||
);
|
||||
const load = useCallback(
|
||||
(page: number) => fetchKnowledgeList(request, page, request.pageSize),
|
||||
[request],
|
||||
);
|
||||
const load = useCallback((page: number) => fetchKnowledgeList(request, page), [request]);
|
||||
const page = usePagedResource(load, request, t("ai.could_not_load_knowledge"));
|
||||
|
||||
useEffect(() => {
|
||||
@@ -117,6 +126,8 @@ export function useKnowledgeLibrary() {
|
||||
itemsLoading: page.loading,
|
||||
page: page.page,
|
||||
pageCount: page.pageCount,
|
||||
pageSize: page.pageSize || pageSize,
|
||||
setPageSize,
|
||||
total: page.total,
|
||||
setPage: page.setPage,
|
||||
reload,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useState, type ReactNode } from "react";
|
||||
|
||||
import { isManager } from "../../auth/access";
|
||||
import { ChatMobileHeader } from "../../layout/ChatMobileHeader";
|
||||
import { Hint } from "../../shared/Hint";
|
||||
import { Icon } from "../../shared/icons";
|
||||
@@ -49,6 +50,7 @@ export function ChatPage({
|
||||
return (
|
||||
<ConversationWorkspace
|
||||
isOwner={user.role === "OWNER"}
|
||||
canDelete={isManager(user)}
|
||||
initialConversationId={initialConversationId}
|
||||
scope={scope}
|
||||
setScope={setScope}
|
||||
|
||||
@@ -63,11 +63,20 @@ export function AudioCallOverlay(props: Props) {
|
||||
: builtStatus;
|
||||
|
||||
const onAccept = () => { void rtc.start(); };
|
||||
// «Завершить» обязано сработать всегда. Токен доступа есть не в каждом
|
||||
// состоянии (карточку звонка могли открыть из списка), а сам запрос может
|
||||
// упасть — тогда звонок заканчивается вторым путём, по сессии оператора.
|
||||
const finish = async () => {
|
||||
if (!call || isTerminalCallStatus(call.status)) return;
|
||||
const token = props.access?.accessToken;
|
||||
if (!token || !call || isTerminalCallStatus(call.status)) return;
|
||||
try { props.onCallChange(await endCallByAccess(token)); }
|
||||
finally { rtc.stop(); }
|
||||
try {
|
||||
if (token) props.onCallChange(await endCallByAccess(token));
|
||||
else props.onCancel();
|
||||
} catch {
|
||||
props.onCancel();
|
||||
} finally {
|
||||
rtc.stop();
|
||||
}
|
||||
};
|
||||
const onEnd = () => { void finish(); };
|
||||
const endAndClose = async () => {
|
||||
|
||||
@@ -6,23 +6,29 @@ import { Icon } from "../../shared/icons";
|
||||
import { Button } from "../../shared/ui-controls";
|
||||
import { t } from "../../i18n";
|
||||
|
||||
// Меню «⋯» над перепиской: вернуть в очередь, закрыть, спам, удалить.
|
||||
// Удаление стирает диалог вместе с перепиской и доступно только владельцу и
|
||||
// администратору — у оператора этого пункта в меню нет.
|
||||
|
||||
export function ConversationActions({
|
||||
open,
|
||||
canReturnQueue,
|
||||
canDelete,
|
||||
onClose,
|
||||
onSpam,
|
||||
onReturnQueue,
|
||||
onArchive,
|
||||
onDelete,
|
||||
}: {
|
||||
open: boolean;
|
||||
canReturnQueue: boolean;
|
||||
canDelete: boolean;
|
||||
onClose: () => void;
|
||||
onSpam: () => Promise<boolean>;
|
||||
onReturnQueue: () => void;
|
||||
onArchive: () => Promise<boolean>;
|
||||
onDelete: () => Promise<boolean>;
|
||||
}) {
|
||||
const [confirmSpam, setConfirmSpam] = useState(false);
|
||||
const [confirmArchive, setConfirmArchive] = useState(false);
|
||||
const [confirmDelete, setConfirmDelete] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
if (!open) return null;
|
||||
@@ -36,10 +42,10 @@ export function ConversationActions({
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmArchiveAction() {
|
||||
async function confirmDeleteAction() {
|
||||
setBusy(true);
|
||||
try {
|
||||
if (await onArchive()) setConfirmArchive(false);
|
||||
if (await onDelete()) setConfirmDelete(false);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
@@ -61,10 +67,12 @@ export function ConversationActions({
|
||||
key: "spam",
|
||||
label: <button className="danger" type="button" onClick={() => setConfirmSpam(true)}><Icon name="warning" size={15} />{t("conversations.mark_as_spam")}</button>,
|
||||
},
|
||||
{
|
||||
key: "archive",
|
||||
label: <button className="danger" type="button" onClick={() => setConfirmArchive(true)}><Icon name="trash" size={15} />{t("conversations.delete_conversation")}</button>,
|
||||
},
|
||||
...(canDelete
|
||||
? [{
|
||||
key: "delete",
|
||||
label: <button className="danger" type="button" onClick={() => setConfirmDelete(true)}><Icon name="trash" size={15} />{t("conversations.delete_conversation")}</button>,
|
||||
}]
|
||||
: []),
|
||||
];
|
||||
|
||||
return (
|
||||
@@ -85,15 +93,15 @@ export function ConversationActions({
|
||||
</>}
|
||||
/>
|
||||
<DecisionDialog
|
||||
open={confirmArchive}
|
||||
onClose={() => !busy && setConfirmArchive(false)}
|
||||
open={confirmDelete}
|
||||
onClose={() => !busy && setConfirmDelete(false)}
|
||||
tone="danger"
|
||||
icon="trash"
|
||||
title={t("conversations.delete_conversation_2")}
|
||||
description={t("conversations.conversation_moves_archive_leaves_lists")}
|
||||
description={t("conversations.conversation_and_history_gone_forever")}
|
||||
actions={<>
|
||||
<Button variant="secondary" disabled={busy} onClick={() => setConfirmArchive(false)}>{t("common.cancel")}</Button>
|
||||
<Button variant="danger-outline" icon="trash" disabled={busy} onClick={() => void confirmArchiveAction()}>{t("common.delete")}</Button>
|
||||
<Button variant="secondary" disabled={busy} onClick={() => setConfirmDelete(false)}>{t("common.cancel")}</Button>
|
||||
<Button variant="danger-outline" icon="trash" disabled={busy} onClick={() => void confirmDeleteAction()}>{t("common.delete")}</Button>
|
||||
</>}
|
||||
/>
|
||||
</>
|
||||
|
||||
@@ -19,7 +19,7 @@ function fmtTime(value: string): string {
|
||||
return fmt.time(value);
|
||||
}
|
||||
|
||||
export function ConversationThread({ controlMode, dialog, detail, history, isOwner = false, onClaim, onRelease, onClose, onSpam, onReturnQueue, onArchive, onToggleContext, onMobileBack, onExpandList, viewerId = null }: { controlMode: ControlMode; dialog: ConversationListItem | null; detail: ApiConversation | null; history: ConversationHistory; isOwner?: boolean; onClaim: () => void; onRelease: () => void; onClose: () => void; onSpam: () => Promise<boolean>; onReturnQueue: () => void; onArchive: () => Promise<boolean>; onToggleContext?: () => void; onMobileBack?: () => void; onExpandList?: () => void; viewerId?: number | null }) {
|
||||
export function ConversationThread({ controlMode, dialog, detail, history, isOwner = false, onClaim, onRelease, onClose, onSpam, onReturnQueue, canDelete = false, onDelete, onToggleContext, onMobileBack, onExpandList, viewerId = null }: { controlMode: ControlMode; dialog: ConversationListItem | null; detail: ApiConversation | null; history: ConversationHistory; isOwner?: boolean; onClaim: () => void; onRelease: () => void; onClose: () => void; onSpam: () => Promise<boolean>; onReturnQueue: () => void; canDelete?: boolean; onDelete: () => Promise<boolean>; onToggleContext?: () => void; onMobileBack?: () => void; onExpandList?: () => void; viewerId?: number | null }) {
|
||||
const timelineRef = useRef<HTMLDivElement>(null);
|
||||
const messages = history.messages;
|
||||
// Лента держит низ при новых репликах и догружает предыдущие при подходе к
|
||||
@@ -71,7 +71,7 @@ export function ConversationThread({ controlMode, dialog, detail, history, isOwn
|
||||
{controlMode === "human" && <button className="sales-secondary-action" onClick={onRelease}>{t("conversations.hand_back_ai")}</button>}
|
||||
{/* Кадр S2: на узком экране контекст-панель — выдвижная, кнопка в шапке. */}
|
||||
{onToggleContext && <IconButton icon="user" label={t("conversations.conversation_context")} className="ctx-toggle" onClick={onToggleContext} />}
|
||||
<ConversationActions open={detail?.lifecycle === "OPEN"} canReturnQueue={controlMode === "human"} onClose={onClose} onSpam={onSpam} onReturnQueue={onReturnQueue} onArchive={onArchive} />
|
||||
<ConversationActions open={detail?.lifecycle === "OPEN"} canReturnQueue={controlMode === "human"} onClose={onClose} onSpam={onSpam} onReturnQueue={onReturnQueue} canDelete={canDelete} onDelete={onDelete} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="sales-timeline" ref={timelineRef} onScroll={onScroll}>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
||||
|
||||
import { ApiError } from "../../api/client";
|
||||
import { CallOverlay } from "./CallOverlay";
|
||||
import { Composer } from "./Composer";
|
||||
import { ConversationThread } from "./ConversationThread";
|
||||
@@ -9,10 +10,10 @@ import {
|
||||
claimConversation,
|
||||
closeConversation,
|
||||
controlModeOf,
|
||||
deleteConversation,
|
||||
fetchConversation,
|
||||
markConversationAsSpam,
|
||||
releaseConversation,
|
||||
setConversationArchived,
|
||||
returnToQueue,
|
||||
toConversationListItem,
|
||||
type ApiConversation,
|
||||
@@ -55,8 +56,10 @@ import { t } from "../../i18n";
|
||||
// backend по группам (ADR-CHATBALLS-0043); страница параметризуется заголовком,
|
||||
// placeholder поиска и правой панелью через render-prop. Список и история —
|
||||
// серверные окна: ни то, ни другое целиком не запрашивается.
|
||||
export function ConversationWorkspace({ isOwner = false, viewerId = null, listTitle, searchPlaceholder, renderContextPanel, mobileHeader, hint, initialConversationId, scope, setScope, counters, showScopeSwitcher = true }: {
|
||||
export function ConversationWorkspace({ isOwner = false, canDelete = false, viewerId = null, listTitle, searchPlaceholder, renderContextPanel, mobileHeader, hint, initialConversationId, scope, setScope, counters, showScopeSwitcher = true }: {
|
||||
isOwner?: boolean;
|
||||
/** Удалять диалоги могут владелец и администратор (то же проверяет сервер). */
|
||||
canDelete?: boolean;
|
||||
listTitle?: string;
|
||||
searchPlaceholder?: string;
|
||||
renderContextPanel: (ctx: { dialog: ConversationListItem | null; detail: ApiConversation | null; applyConversation: (updated: ApiConversation) => void; startCall: ((kind: "AUDIO" | "VIDEO") => void) | null; closeContext: () => void; assignmentTimeoutMinutes?: number }) => ReactNode;
|
||||
@@ -119,8 +122,17 @@ export function ConversationWorkspace({ isOwner = false, viewerId = null, listTi
|
||||
setDetail(loaded);
|
||||
setDetailError("");
|
||||
}
|
||||
} catch {
|
||||
if (selectedIdRef.current === id) setDetailError(t("conversations.could_not_load_conversation"));
|
||||
} catch (error) {
|
||||
if (selectedIdRef.current !== id) return;
|
||||
// Диалог удалили — возможно, другим администратором. Карточки больше
|
||||
// нет, и держать выбор не на чем; список обновит событие инбокса.
|
||||
if (error instanceof ApiError && error.status === 404) {
|
||||
setSelectedId(null);
|
||||
setDetail(null);
|
||||
setDetailError("");
|
||||
return;
|
||||
}
|
||||
setDetailError(t("conversations.could_not_load_conversation"));
|
||||
}
|
||||
}, []);
|
||||
|
||||
@@ -193,11 +205,21 @@ export function ConversationWorkspace({ isOwner = false, viewerId = null, listTi
|
||||
const onReturnQueue = () => { void updateConversation(returnToQueue); };
|
||||
const onClose = () => { void updateConversation(closeConversation); };
|
||||
const onSpam = () => updateConversation(markConversationAsSpam);
|
||||
const onArchive = async () => {
|
||||
const done = await updateConversation((id) => setConversationArchived(id, true));
|
||||
// Архивный диалог исчезает из списка — снимаем выбор.
|
||||
if (done) setSelectedId(null);
|
||||
return done;
|
||||
// Удаление — не действие над диалогом, а его конец: обновлять нечего, из
|
||||
// списка он уходит вместе с перепиской.
|
||||
const onDelete = async () => {
|
||||
if (selectedId == null) return false;
|
||||
setActionError("");
|
||||
try {
|
||||
await deleteConversation(selectedId);
|
||||
setDetail(null);
|
||||
setSelectedId(null);
|
||||
void list.refresh();
|
||||
return true;
|
||||
} catch (error) {
|
||||
setActionError(error instanceof Error ? error.message : t("conversations.could_not_delete_conversation"));
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -246,7 +268,7 @@ export function ConversationWorkspace({ isOwner = false, viewerId = null, listTi
|
||||
{selectedDialog && (
|
||||
<section className="sales-conversation enter-surface" key={selectedDialog.id}>
|
||||
{ctxOpen && <button className="ctx-backdrop" type="button" aria-label={t("admin.close_panel")} onClick={() => setCtxOpen(false)} />}
|
||||
<ConversationThread controlMode={controlMode} dialog={selectedDialog} detail={detail} history={history} isOwner={isOwner} onExpandList={listCollapsed ? () => setListCollapsed(false) : undefined} viewerId={viewerId} onClaim={onClaim} onRelease={onRelease} onClose={onClose} onSpam={onSpam} onReturnQueue={onReturnQueue} onArchive={onArchive} onToggleContext={() => setCtxOpen((open) => !open)} onMobileBack={() => setMobileDialogOpen(false)} />
|
||||
<ConversationThread controlMode={controlMode} dialog={selectedDialog} detail={detail} history={history} isOwner={isOwner} onExpandList={listCollapsed ? () => setListCollapsed(false) : undefined} viewerId={viewerId} onClaim={onClaim} onRelease={onRelease} onClose={onClose} onSpam={onSpam} onReturnQueue={onReturnQueue} canDelete={canDelete} onDelete={onDelete} onToggleContext={() => setCtxOpen((open) => !open)} onMobileBack={() => setMobileDialogOpen(false)} />
|
||||
{(detailError || actionError || history.errorText) && <div className="sales-conversation-error">{detailError || actionError || history.errorText}</div>}
|
||||
<CallOverlay
|
||||
open={callController.open}
|
||||
|
||||
@@ -2,8 +2,9 @@ import { Dropdown } from "antd";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { Icon } from "../../shared/icons";
|
||||
import { SearchInput } from "../../shared/ui-controls";
|
||||
import { assigneeMenuItems, initials, SmallAvatar } from "./assigneeOptions";
|
||||
import { DialogLabels } from "./DialogLabels";
|
||||
import { NoteSection } from "./DialogNoteSection";
|
||||
import { assigneeMenuItems, SmallAvatar } from "./assigneeOptions";
|
||||
import { WaitingBlock } from "./WaitingBlock";
|
||||
import { PriorityBars } from "./DialogList";
|
||||
import { useEmployeeDirectory } from "./useEmployeeDirectory";
|
||||
@@ -11,17 +12,12 @@ import { statusFor } from "./data";
|
||||
import {
|
||||
agentColorOf,
|
||||
controlModeOf,
|
||||
createConversationLabel,
|
||||
fetchConversationLabels,
|
||||
groupColorOf,
|
||||
setConversationArchived,
|
||||
setConversationAssignee,
|
||||
setConversationGroup,
|
||||
setConversationLabels,
|
||||
setConversationNote,
|
||||
setConversationPriority,
|
||||
type ApiConversation,
|
||||
type ConversationLabelRef,
|
||||
type ConversationPriority,
|
||||
} from "./model";
|
||||
import type { EmployeeGroupRef } from "../../types";
|
||||
@@ -62,18 +58,11 @@ export function DialogControls({
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [errorText, setErrorText] = useState("");
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
const [labels, setLabels] = useState<ConversationLabelRef[]>([]);
|
||||
const [newLabel, setNewLabel] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
setNewLabel("");
|
||||
setErrorText("");
|
||||
}, [detail.id]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchConversationLabels().then(setLabels).catch(() => setLabels([]));
|
||||
}, []);
|
||||
|
||||
async function run(action: () => Promise<ApiConversation>) {
|
||||
setBusy(true);
|
||||
setErrorText("");
|
||||
@@ -86,30 +75,6 @@ export function DialogControls({
|
||||
}
|
||||
}
|
||||
|
||||
async function addLabel(id?: number) {
|
||||
let labelId = id;
|
||||
setBusy(true);
|
||||
setErrorText("");
|
||||
try {
|
||||
if (labelId === undefined) {
|
||||
const name = newLabel.trim();
|
||||
if (!name) return;
|
||||
const label = await createConversationLabel(name);
|
||||
setLabels((current) => (current.some((item) => item.id === label.id) ? current : [...current, label]));
|
||||
labelId = label.id;
|
||||
setNewLabel("");
|
||||
}
|
||||
const ids = [...new Set([...detail.labels.map((item) => item.id), labelId])];
|
||||
applyConversation(await setConversationLabels(detail.id, ids));
|
||||
} catch (error) {
|
||||
setErrorText(error instanceof Error ? error.message : t("conversations.could_not_add_label"));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
const assignedIds = new Set(detail.labels.map((item) => item.id));
|
||||
const availableLabels = labels.filter((item) => !assignedIds.has(item.id));
|
||||
const assignee = detail.assignedOperator;
|
||||
const assigneeHere = assignee ? directory.employees.some((employee) => employee.id === assignee.id && employee.online) : false;
|
||||
const assigneeLabel = assignee ? `${assignee.name}${viewerId != null && assignee.id === viewerId ? t("common.you_suffix") : ""}` : t("conversations.unassigned");
|
||||
@@ -205,57 +170,7 @@ export function DialogControls({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label className="ctx-label">{t("conversations.labels")}</label>
|
||||
<div className="ctx-labels">
|
||||
<Dropdown
|
||||
disabled={busy}
|
||||
trigger={["click"]}
|
||||
overlayClassName="app-dropdown is-wide ctx-labels-menu"
|
||||
menu={{
|
||||
items: [
|
||||
...availableLabels.map((label) => ({
|
||||
key: label.id,
|
||||
label: <button type="button" onClick={() => void addLabel(label.id)}><i className="ctx-dot is-square" style={{ background: label.color || "var(--n-5)" }} />{label.name}</button>,
|
||||
})),
|
||||
{
|
||||
key: "new",
|
||||
label: (
|
||||
<div className="ctx-new-label" onClick={(event) => event.stopPropagation()}>
|
||||
<input
|
||||
placeholder={t("conversations.new_label")}
|
||||
value={newLabel}
|
||||
onChange={(event) => setNewLabel(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
void addLabel();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<button type="button" disabled={busy || !newLabel.trim()} onClick={() => void addLabel()}><Icon name="plus" size={13} /></button>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
],
|
||||
}}
|
||||
>
|
||||
<button type="button" className="ctx-add-label"><Icon name="plus" size={13} />{t("conversations.add")}</button>
|
||||
</Dropdown>
|
||||
{detail.labels.map((label) => (
|
||||
<b className="ctx-label-chip" key={label.id}>
|
||||
<i style={{ background: label.color || "var(--n-5)" }} />
|
||||
{label.name}
|
||||
<button
|
||||
aria-label={t("conversations.remove_label", { name: label.name })}
|
||||
disabled={busy}
|
||||
type="button"
|
||||
onClick={() => void run(() => setConversationLabels(detail.id, detail.labels.filter((item) => item.id !== label.id).map((item) => item.id)))}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</b>
|
||||
))}
|
||||
</div>
|
||||
<DialogLabels detail={detail} busy={busy} setBusy={setBusy} setErrorText={setErrorText} applyConversation={applyConversation} run={run} />
|
||||
|
||||
<div className="ctx-meta-row">
|
||||
<span>{detail.waitingSince ? t("conversations.in_queue_since") : t("conversations.started")}</span>
|
||||
@@ -270,36 +185,6 @@ export function DialogControls({
|
||||
);
|
||||
}
|
||||
|
||||
function NoteSection({ detail, busy, onSave }: { detail: ApiConversation; busy: boolean; onSave: (note: string) => Promise<void> }) {
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [draft, setDraft] = useState(detail.note);
|
||||
|
||||
useEffect(() => {
|
||||
setEditing(false);
|
||||
setDraft(detail.note);
|
||||
}, [detail.id, detail.note]);
|
||||
|
||||
return (
|
||||
<section className="ctx-section is-note">
|
||||
<div className="ctx-section-head">
|
||||
<h4>{t("conversations.note")}</h4>
|
||||
<button type="button" aria-label={t("conversations.edit_note")} onClick={() => setEditing(true)}><Icon name="edit" size={14} /></button>
|
||||
</div>
|
||||
{editing ? (
|
||||
<div className="ctx-note is-editing">
|
||||
<textarea autoFocus disabled={busy} placeholder={t("conversations.internal_note_customer_does_not")} rows={3} value={draft} onChange={(event) => setDraft(event.target.value)} />
|
||||
<div className="ctx-note-actions">
|
||||
<button type="button" disabled={busy} onClick={() => { setDraft(detail.note); setEditing(false); }}>{t("common.cancel")}</button>
|
||||
<button type="button" className="primary" disabled={busy} onClick={() => void onSave(draft).then(() => setEditing(false))}>{t("common.save")}</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className={`ctx-note ${detail.note ? "" : "is-empty"}`} onClick={() => setEditing(true)}>{detail.note || t("conversations.no_notes")}</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
// «сегодня, 17:02» · «вчера, 11:05» · «12 авг, 09:30»
|
||||
export function startedLabel(iso: string): string {
|
||||
const date = new Date(iso);
|
||||
@@ -312,21 +197,3 @@ export function startedLabel(iso: string): string {
|
||||
return fmt.shortDateTime(date);
|
||||
}
|
||||
|
||||
export function archiveConversationAction(
|
||||
detail: ApiConversation,
|
||||
applyConversation: (updated: ApiConversation) => void,
|
||||
onError: (text: string) => void,
|
||||
): Promise<boolean> {
|
||||
return setConversationArchived(detail.id, true)
|
||||
.then((updated) => {
|
||||
applyConversation(updated);
|
||||
return true;
|
||||
})
|
||||
.catch((error) => {
|
||||
onError(error instanceof Error ? error.message : t("conversations.could_not_delete_conversation"));
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
// Аватар 22px в поле «Ответственный»: фото сотрудника или инициалы.
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
import { Dropdown } from "antd";
|
||||
import { useEffect, useState } from "react";
|
||||
import { t } from "../../i18n";
|
||||
import { Icon } from "../../shared/icons";
|
||||
import { createConversationLabel, fetchConversationLabels, setConversationLabels,
|
||||
type ApiConversation, type ConversationLabelRef } from "./model";
|
||||
import "./dialogLabels.css";
|
||||
|
||||
export function DialogLabels({ detail, busy, setBusy, setErrorText, applyConversation, run }: {
|
||||
detail: ApiConversation;
|
||||
busy: boolean;
|
||||
setBusy: (busy: boolean) => void;
|
||||
setErrorText: (error: string) => void;
|
||||
applyConversation: (updated: ApiConversation) => void;
|
||||
run: (action: () => Promise<ApiConversation>) => Promise<void>;
|
||||
}) {
|
||||
const [labels, setLabels] = useState<ConversationLabelRef[]>([]);
|
||||
const [newLabel, setNewLabel] = useState("");
|
||||
useEffect(() => { setNewLabel(""); }, [detail.id]);
|
||||
useEffect(() => {
|
||||
fetchConversationLabels().then(setLabels).catch((error) => {
|
||||
setErrorText(error instanceof Error ? error.message : t("conversations.could_not_add_label"));
|
||||
});
|
||||
}, [setErrorText]);
|
||||
|
||||
async function addLabel(id?: number) {
|
||||
if (busy) return;
|
||||
let labelId = id;
|
||||
setBusy(true);
|
||||
setErrorText("");
|
||||
try {
|
||||
if (labelId === undefined) {
|
||||
const name = newLabel.trim();
|
||||
if (!name) return;
|
||||
const label = await createConversationLabel(name);
|
||||
setLabels((current) => (current.some((item) => item.id === label.id) ? current : [...current, label]));
|
||||
labelId = label.id;
|
||||
setNewLabel("");
|
||||
}
|
||||
const ids = [...new Set([...detail.labels.map((item) => item.id), labelId])];
|
||||
applyConversation(await setConversationLabels(detail.id, ids));
|
||||
} catch (error) {
|
||||
setErrorText(error instanceof Error ? error.message : t("conversations.could_not_add_label"));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
const assignedIds = new Set(detail.labels.map((item) => item.id));
|
||||
const availableLabels = labels.filter((item) => !assignedIds.has(item.id));
|
||||
return (
|
||||
<>
|
||||
<label className="ctx-label">{t("conversations.labels")}</label>
|
||||
<div className="ctx-labels">
|
||||
<Dropdown
|
||||
disabled={busy}
|
||||
trigger={["click"]}
|
||||
overlayClassName="app-dropdown is-wide ctx-labels-menu"
|
||||
menu={{
|
||||
items: [
|
||||
...availableLabels.map((label) => ({
|
||||
key: label.id,
|
||||
label: <button type="button" onClick={() => void addLabel(label.id)}><i className="ctx-dot is-square" style={{ background: label.color || "var(--n-5)" }} />{label.name}</button>,
|
||||
})),
|
||||
{
|
||||
key: "new",
|
||||
label: (
|
||||
<div className="ctx-new-label" onClick={(event) => event.stopPropagation()}>
|
||||
<input
|
||||
aria-label={t("conversations.new_label")}
|
||||
disabled={busy}
|
||||
placeholder={t("conversations.new_label")}
|
||||
value={newLabel}
|
||||
onChange={(event) => setNewLabel(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter") {
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
void addLabel();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<button type="button" aria-label={t("conversations.add")} disabled={busy || !newLabel.trim()} onClick={() => void addLabel()}><Icon name="plus" size={13} /></button>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
],
|
||||
}}
|
||||
>
|
||||
<button type="button" className="ctx-add-label"><Icon name="plus" size={13} />{t("conversations.add")}</button>
|
||||
</Dropdown>
|
||||
{detail.labels.map((label) => (
|
||||
<b className="ctx-label-chip" key={label.id}>
|
||||
<i style={{ background: label.color || "var(--n-5)" }} />
|
||||
{label.name}
|
||||
<button
|
||||
aria-label={t("conversations.remove_label", { name: label.name })}
|
||||
disabled={busy}
|
||||
type="button"
|
||||
onClick={() => void run(() => setConversationLabels(detail.id, detail.labels.filter((item) => item.id !== label.id).map((item) => item.id)))}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</b>
|
||||
))}
|
||||
</div>
|
||||
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -90,7 +90,7 @@ export function DialogList({ title = t("common.conversations"), searchPlaceholde
|
||||
{hint}
|
||||
{/* Что значат две вкладки ожидания — прямо там, где их выбирают. */}
|
||||
{(listTab === "queue" || listTab === "onMe") && (
|
||||
<div className="sales-wait-note">{t("conversations.queue_tabs_note")}</div>
|
||||
<div className="sales-dialog-tabs-note">{t("conversations.queue_tabs_note")}</div>
|
||||
)}
|
||||
<div className="sales-dialog-list-body" onScroll={onScroll}>
|
||||
{errorText && <div className="sales-wait-note sales-load-error">{errorText}</div>}
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { t } from "../../i18n";
|
||||
import { Icon } from "../../shared/icons";
|
||||
import type { ApiConversation } from "./model";
|
||||
|
||||
export function NoteSection({ detail, busy, onSave }: { detail: ApiConversation; busy: boolean; onSave: (note: string) => Promise<void> }) {
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [draft, setDraft] = useState(detail.note);
|
||||
|
||||
useEffect(() => {
|
||||
setEditing(false);
|
||||
setDraft(detail.note);
|
||||
}, [detail.id, detail.note]);
|
||||
|
||||
return (
|
||||
<section className="ctx-section is-note">
|
||||
<div className="ctx-section-head">
|
||||
<h4>{t("conversations.note")}</h4>
|
||||
<button type="button" aria-label={t("conversations.edit_note")} onClick={() => setEditing(true)}><Icon name="edit" size={14} /></button>
|
||||
</div>
|
||||
{editing ? (
|
||||
<div className="ctx-note is-editing">
|
||||
<textarea autoFocus disabled={busy} placeholder={t("conversations.internal_note_customer_does_not")} rows={3} value={draft} onChange={(event) => setDraft(event.target.value)} />
|
||||
<div className="ctx-note-actions">
|
||||
<button type="button" disabled={busy} onClick={() => { setDraft(detail.note); setEditing(false); }}>{t("common.cancel")}</button>
|
||||
<button type="button" className="primary" disabled={busy} onClick={() => void onSave(draft).then(() => setEditing(false))}>{t("common.save")}</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className={`ctx-note ${detail.note ? "" : "is-empty"}`} onClick={() => setEditing(true)}>{detail.note || t("conversations.no_notes")}</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -66,11 +66,19 @@ export function VideoCallOverlay(props: Props) {
|
||||
const channel = providerMeta[props.dialog.channel];
|
||||
const subtitle = subtitleFor(mode, call, status);
|
||||
const mediaCaption = rtc.mediaIssue === "devices" ? t("conversations.no_access_camera_microphone") : rtc.mediaIssue === "video" ? t("conversations.camera_unavailable") : t("conversations.camera_off");
|
||||
// «Завершить» обязано сработать всегда: нет токена доступа или запрос не
|
||||
// прошёл — звонок заканчивается вторым путём, по сессии оператора.
|
||||
const finish = async () => {
|
||||
if (!call || TERMINAL[call.status]) return;
|
||||
const token = props.access?.accessToken;
|
||||
if (!token || !call || TERMINAL[call.status]) return;
|
||||
try { props.onCallChange(await endCallByAccess(token)); }
|
||||
finally { rtc.stop(); }
|
||||
try {
|
||||
if (token) props.onCallChange(await endCallByAccess(token));
|
||||
else props.onCancel();
|
||||
} catch {
|
||||
props.onCancel();
|
||||
} finally {
|
||||
rtc.stop();
|
||||
}
|
||||
};
|
||||
const endAndClose = async () => {
|
||||
if (mode === "active" || mode === "reconnecting" || mode === "connecting" || mode === "precall") await finish();
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
import { voiceWaveHeights } from "@chatballs/ui";
|
||||
|
||||
import { Icon } from "../../shared/icons";
|
||||
import { resolveApiUrl } from "../../api/client";
|
||||
import { transcribeMessage, type ApiMessage } from "./model";
|
||||
@@ -13,18 +15,6 @@ export function formatDuration(totalSeconds: number): string {
|
||||
return `${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
// Детерминированная волна из id: реальная амплитуда не хранится, рисунок
|
||||
// стабилен между рендерами (как в макете).
|
||||
function waveHeights(seed: number, bars = 32): number[] {
|
||||
const heights: number[] = [];
|
||||
let value = seed || 1;
|
||||
for (let index = 0; index < bars; index += 1) {
|
||||
value = (value * 1103515245 + 12345) % 2147483648;
|
||||
heights.push(4 + (value % 17));
|
||||
}
|
||||
return heights;
|
||||
}
|
||||
|
||||
export function VoiceMessage({ message: incoming }: { message: ApiMessage }) {
|
||||
// Локальная копия: расшифровка приходит из POST-ответа сразу, а фоновый
|
||||
// поллинг detail затем подтверждает её с сервера.
|
||||
@@ -38,7 +28,7 @@ export function VoiceMessage({ message: incoming }: { message: ApiMessage }) {
|
||||
const [transcribing, setTranscribing] = useState(false);
|
||||
const [showTranscript, setShowTranscript] = useState(true);
|
||||
const [errorText, setErrorText] = useState("");
|
||||
const bars = waveHeights(message.id);
|
||||
const bars = voiceWaveHeights(message.id);
|
||||
|
||||
useEffect(() => () => {
|
||||
audioRef.current?.pause();
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
/* Меню меток: список готовых меток и строка «новая метка».
|
||||
|
||||
Меню antd — вертикальное, а значит с `overflow-y: auto`; по правилам CSS
|
||||
вторая ось тогда тоже перестаёт быть `visible` и любой лишний пиксель строки
|
||||
ввода превращается в горизонтальную полосу прокрутки под полем. Полосы здесь
|
||||
быть не должно: строка обязана вписываться в ширину меню. */
|
||||
.app-dropdown.ctx-labels-menu .ant-dropdown-menu {
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.app-dropdown.ctx-labels-menu .ant-dropdown-menu-title-content {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.app-dropdown.ctx-labels-menu .ctx-new-label {
|
||||
box-sizing: border-box;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
/* Keep the inline add button from inheriting full-width menu item styles. */
|
||||
.app-dropdown.ctx-labels-menu .ctx-new-label button {
|
||||
width: 30px;
|
||||
min-height: 30px;
|
||||
padding: 0;
|
||||
color: #ffffff;
|
||||
background: var(--primary);
|
||||
}
|
||||
@@ -351,6 +351,9 @@ export const setConversationLabels = (id: number, labelIds: number[]) =>
|
||||
conversationAction(id, "labels", { labelIds });
|
||||
export const setConversationArchived = (id: number, archived: boolean) =>
|
||||
conversationAction(id, "archive", { archived });
|
||||
/** Удалить диалог вместе с перепиской. Право — у владельца и администратора. */
|
||||
export const deleteConversation = (id: number) =>
|
||||
api<void>(`/api/v1/conversations/${id}/`, { method: "DELETE" });
|
||||
export const setConversationGroup = (id: number, groupId: number | null) =>
|
||||
conversationAction(id, "group", { groupId });
|
||||
export const setConversationAssignee = (id: number, userId: number | null) =>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -254,9 +254,11 @@ export const en: Record<MessageKey, Message> = {
|
||||
"ai.cannot_delete_named": "“{name}” cannot be deleted: {reason}. Move its contents out first.",
|
||||
"ai.categories": "CATEGORIES",
|
||||
"ai.categories_do_not_nest_deeper": "Categories do not nest deeper than three levels",
|
||||
"ai.collapse_category": "Collapse “{name}”",
|
||||
"ai.category": "Category",
|
||||
"ai.category_cannot_moved_inside_itself": "A category cannot be moved inside itself.",
|
||||
"ai.category_empty_will_delete": "The category “{name}” is empty and will be deleted.",
|
||||
"ai.expand_category": "Expand “{name}”",
|
||||
"ai.category_file_matches_current_one": "the category in the file matches the current one",
|
||||
"ai.category_has_no_items": "The category “{name}” has no knowledge yet",
|
||||
"ai.category_named": "category “{name}”",
|
||||
@@ -352,10 +354,10 @@ export const en: Record<MessageKey, Message> = {
|
||||
"ai.how_agent_will_answer": "HOW THE AGENT WILL ANSWER",
|
||||
"ai.how_agent_will_answer_2": "How the agent will answer",
|
||||
"ai.how_should_speak": "How it should speak",
|
||||
"ai.import_category_policy": "Missing categories from the file are created automatically. Without a path, new documents go to “No category” and existing documents keep their category.",
|
||||
"ai.import_category_creation": "Missing categories will be created automatically.",
|
||||
"ai.import_documents": "Import {count}",
|
||||
"ai.import_finished": "Import finished",
|
||||
"ai.import_never_creates_categories_implicitly": "Import never creates categories implicitly: a document with a non-existent",
|
||||
"ai.import_path_tail": "is rejected on its own, the rest are applied. Without a path a document lands in “No category”.",
|
||||
"ai.index_up_date": "The index is up to date",
|
||||
"ai.indexing": "INDEXING",
|
||||
"ai.insert_link_into_text": "Insert a link into the text",
|
||||
@@ -381,7 +383,6 @@ export const en: Record<MessageKey, Message> = {
|
||||
"ai.list": "List",
|
||||
"ai.manage": "Manage",
|
||||
"ai.material_import": "Material import",
|
||||
"ai.missing_category_path": "there is no category “{name}” — import never creates categories. Create it or drop the last level of the path.",
|
||||
"ai.move": "Move",
|
||||
"ai.move_category": "Move to a category",
|
||||
"ai.name_group_all_takes_start": "A name and a group are all it takes to start. Instructions, knowledge and connections are set later on the card.",
|
||||
@@ -421,6 +422,7 @@ export const en: Record<MessageKey, Message> = {
|
||||
"ai.pick_file": "Pick a file",
|
||||
"ai.pick_provider": "pick a provider",
|
||||
"ai.pick_them_from_disk": "pick them from disk",
|
||||
"ai.other_knowledge": "Other knowledge",
|
||||
"ai.portal_named": "Portal · {name}",
|
||||
"ai.preview": "Preview",
|
||||
"ai.preview_of": "PREVIEW · {what}",
|
||||
@@ -438,6 +440,7 @@ export const en: Record<MessageKey, Message> = {
|
||||
"ai.search_by_title": "Search by title",
|
||||
"ai.search_by_title_description": "Search by title and description",
|
||||
"ai.select": "Select",
|
||||
"ai.select_all": "Select all",
|
||||
"ai.select_every_knowledge_item_page": "Select every knowledge item on the page",
|
||||
"ai.select_item": "Select “{title}”",
|
||||
"ai.select_knowledge": "Select knowledge",
|
||||
@@ -649,11 +652,11 @@ export const en: Record<MessageKey, Message> = {
|
||||
"conversations.connecting_call": "Connecting the call",
|
||||
"conversations.conversation": "Conversation",
|
||||
"conversations.conversation_actions": "Conversation actions",
|
||||
"conversations.conversation_and_history_gone_forever": "The conversation and the whole history will be deleted for good. The customer sees an empty chat and its notifications disappear.",
|
||||
"conversations.conversation_closed": "The conversation is closed",
|
||||
"conversations.conversation_context": "Conversation context",
|
||||
"conversations.conversation_has_ended": "The conversation has ended.",
|
||||
"conversations.conversation_list_width": "Conversation list width",
|
||||
"conversations.conversation_moves_archive_leaves_lists": "The conversation moves to the archive and leaves the lists. Only administrators see the archive.",
|
||||
"conversations.conversation_will_closed_replies_stay": "The conversation will be closed for replies and stay in history marked “Spam”.",
|
||||
"conversations.conversation_without_group_visible_every": "A conversation without a group is visible to every operator.",
|
||||
"conversations.conversations_appear_once_customers_write": "Conversations appear once customers write to your agent",
|
||||
@@ -1665,6 +1668,7 @@ export const en: Record<MessageKey, Message> = {
|
||||
"shared.new_knowledge_item": "New knowledge item",
|
||||
"shared.next_page": "Next page",
|
||||
"shared.orange": "Orange",
|
||||
"shared.per_page": "Per page:",
|
||||
"shared.pick_agent": "Pick an agent",
|
||||
"shared.portal_settings": "Portal settings",
|
||||
"shared.previous_page": "Previous page",
|
||||
|
||||
@@ -255,9 +255,11 @@ export const ru = {
|
||||
"ai.cannot_delete_named": "«{name}» удалить нельзя: {reason}. Сначала перенесите содержимое.",
|
||||
"ai.categories": "КАТЕГОРИИ",
|
||||
"ai.categories_do_not_nest_deeper": "Глубже трёх уровней категории не вкладываются",
|
||||
"ai.collapse_category": "Свернуть «{name}»",
|
||||
"ai.category": "Категория",
|
||||
"ai.category_cannot_moved_inside_itself": "Категорию нельзя перенести внутрь себя.",
|
||||
"ai.category_empty_will_delete": "Категория «{name}» пуста и будет удалена.",
|
||||
"ai.expand_category": "Развернуть «{name}»",
|
||||
"ai.category_file_matches_current_one": "категория в файле совпадает с текущей",
|
||||
"ai.category_has_no_items": "В категории «{name}» пока нет знаний",
|
||||
"ai.category_named": "категория «{name}»",
|
||||
@@ -353,10 +355,10 @@ export const ru = {
|
||||
"ai.how_agent_will_answer": "КАК ОТВЕТИТ АГЕНТ",
|
||||
"ai.how_agent_will_answer_2": "Как ответит агент",
|
||||
"ai.how_should_speak": "Как он должен говорить",
|
||||
"ai.import_category_policy": "Недостающие категории из файла создаются автоматически. Без указания пути новый документ попадает в «Без категории», существующий сохраняет свою категорию.",
|
||||
"ai.import_category_creation": "Недостающие категории будут созданы автоматически.",
|
||||
"ai.import_documents": "Импортировать {count}",
|
||||
"ai.import_finished": "Импорт завершён",
|
||||
"ai.import_never_creates_categories_implicitly": "Импорт не создаёт категории неявно: документ с несуществующим",
|
||||
"ai.import_path_tail": "отклоняется отдельно, остальные применяются. Без указания пути документ попадает в «Без категории».",
|
||||
"ai.index_up_date": "Индекс актуален",
|
||||
"ai.indexing": "ИНДЕКСАЦИЯ",
|
||||
"ai.insert_link_into_text": "Вставить ссылку в текст",
|
||||
@@ -382,7 +384,6 @@ export const ru = {
|
||||
"ai.list": "Список",
|
||||
"ai.manage": "Управлять",
|
||||
"ai.material_import": "Импорт материалов",
|
||||
"ai.missing_category_path": "категории «{name}» нет — импорт не создаёт категории. Создайте её или уберите последний уровень пути.",
|
||||
"ai.move": "Переместить",
|
||||
"ai.move_category": "Переместить в категорию",
|
||||
"ai.name_group_all_takes_start": "Имя и группа — всё, что нужно для старта. Инструкции, знания и подключения настраиваются потом на карточке.",
|
||||
@@ -422,6 +423,7 @@ export const ru = {
|
||||
"ai.pick_file": "Выбрать файл",
|
||||
"ai.pick_provider": "выберите провайдера",
|
||||
"ai.pick_them_from_disk": "выберите на диске",
|
||||
"ai.other_knowledge": "Прочие знания",
|
||||
"ai.portal_named": "Портал · {name}",
|
||||
"ai.preview": "Просмотр",
|
||||
"ai.preview_of": "ПРЕДПРОСМОТР · {what}",
|
||||
@@ -439,6 +441,7 @@ export const ru = {
|
||||
"ai.search_by_title": "Поиск по названию",
|
||||
"ai.search_by_title_description": "Поиск по заголовку и описанию",
|
||||
"ai.select": "Выбрать",
|
||||
"ai.select_all": "Выбрать все",
|
||||
"ai.select_every_knowledge_item_page": "Выбрать все знания на странице",
|
||||
"ai.select_item": "Выбрать «{title}»",
|
||||
"ai.select_knowledge": "Выбрать знания",
|
||||
@@ -650,11 +653,11 @@ export const ru = {
|
||||
"conversations.connecting_call": "Соединяем звонок",
|
||||
"conversations.conversation": "Диалог",
|
||||
"conversations.conversation_actions": "Действия диалога",
|
||||
"conversations.conversation_and_history_gone_forever": "Диалог и вся переписка будут удалены безвозвратно. Клиент увидит пустой чат, уведомления по нему исчезнут.",
|
||||
"conversations.conversation_closed": "Диалог закрыт",
|
||||
"conversations.conversation_context": "Контекст диалога",
|
||||
"conversations.conversation_has_ended": "Разговор завершён.",
|
||||
"conversations.conversation_list_width": "Ширина списка диалогов",
|
||||
"conversations.conversation_moves_archive_leaves_lists": "Диалог уйдёт в архив и исчезнет из списков. Архив видят только администраторы.",
|
||||
"conversations.conversation_will_closed_replies_stay": "Диалог будет закрыт для ответов и останется в истории со статусом «Спам».",
|
||||
"conversations.conversation_without_group_visible_every": "Диалог без группы видят все сотрудники.",
|
||||
"conversations.conversations_appear_once_customers_write": "Диалоги появятся, когда клиенты напишут вашему агенту",
|
||||
@@ -1666,6 +1669,7 @@ export const ru = {
|
||||
"shared.new_knowledge_item": "Новое знание",
|
||||
"shared.next_page": "Следующая страница",
|
||||
"shared.orange": "Оранжевый",
|
||||
"shared.per_page": "На странице:",
|
||||
"shared.pick_agent": "Выберите агента",
|
||||
"shared.portal_settings": "Настройки портала",
|
||||
"shared.previous_page": "Предыдущая страница",
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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")}
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { PagedPayload } from "./usePagedResource";
|
||||
|
||||
// Весь набор постранично. Нужен там, где интерфейс работает не со страницей, а
|
||||
// с полным списком: выбор знаний агента, выгрузка. Обычные списки так грузить
|
||||
// нельзя — это ровно то, от чего уходили, когда вводили страницы.
|
||||
|
||||
export async function allPages<T>(load: (page: number) => Promise<PagedPayload<T>>): Promise<T[]> {
|
||||
const items: T[] = [];
|
||||
let page = 1;
|
||||
for (;;) {
|
||||
const payload = await load(page);
|
||||
items.push(...payload.items);
|
||||
if (payload.items.length === 0 || payload.page >= payload.pageCount) return items;
|
||||
page = payload.page + 1;
|
||||
}
|
||||
}
|
||||
@@ -69,3 +69,36 @@
|
||||
font-weight: 600;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
/* «На странице: 20» — выбор размера страницы рядом с подписью подвала. */
|
||||
.pager-size {
|
||||
display: inline-flex;
|
||||
height: 28px;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 0 8px;
|
||||
border: 1px solid var(--n-7);
|
||||
border-radius: 7px;
|
||||
background: var(--surface-card);
|
||||
color: var(--n-3);
|
||||
font-family: inherit;
|
||||
font-size: 12.5px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.pager-size:hover {
|
||||
background: var(--n-9);
|
||||
}
|
||||
|
||||
.pager-size i {
|
||||
color: var(--n-5);
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
.pager-size > svg:last-child {
|
||||
color: var(--n-5);
|
||||
}
|
||||
|
||||
.app-dropdown.is-pager-size .ant-dropdown-menu {
|
||||
width: 108px;
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 };
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -24,6 +24,9 @@ export const ru = {
|
||||
"chat.message_placeholder": "Напишите сообщение…",
|
||||
"chat.operator_answering": "Отвечает специалист",
|
||||
"chat.passing_to_operator": "Передаём оператору",
|
||||
"chat.pause": "Пауза",
|
||||
"chat.play": "Воспроизвести",
|
||||
"chat.could_not_play": "Не удалось воспроизвести запись",
|
||||
"chat.record_voice": "Записать голосовое",
|
||||
"chat.recording": "Идёт запись",
|
||||
"chat.remove_file": "Убрать файл",
|
||||
|
||||
+28
-29
@@ -279,13 +279,21 @@ services:
|
||||
backend-platform:
|
||||
condition: service_healthy
|
||||
|
||||
# Coturn — медиа-relay для TURN fallback (SPEC-CHATBALLS-0013 §11).
|
||||
# Опциональный profile `calls` (ADR-CHATBALLS-0028): включается через COMPOSE_PROFILES=calls.
|
||||
# Host networking: relay использует широкий UDP-диапазон и реальный внешний IP,
|
||||
# публикуется напрямую и не проходит через HTTP reverse proxy. credentials
|
||||
# выдаёт backend по общему CHATBALLS_CALL_TURN_SECRET (static-auth-secret).
|
||||
# Coturn — медиа-relay для звонков (SPEC-CHATBALLS-0013 §11).
|
||||
#
|
||||
# Поднимается вместе со всей установкой и слушает тот же адрес, что и веб:
|
||||
# звонок через симметричный NAT или VPN без relay не соединяется в принципе,
|
||||
# и превращать это в отдельную настройку значит выдавать клиенту заведомо
|
||||
# неработающие звонки. Порт 3478 не спорит с 80 и 443, так что второй IP не
|
||||
# нужен; TURN-over-TLS по умолчанию выключен — он требует сертификата и
|
||||
# 443-го порта, то есть как раз выделенного адреса. Кому нужен TLS (сети,
|
||||
# где наружу разрешён только 443), включает его отдельным compose-override.
|
||||
#
|
||||
# Host networking: relay использует широкий UDP-диапазон и реальный внешний
|
||||
# IP, публикуется напрямую и не проходит через HTTP reverse proxy.
|
||||
# credentials выдаёт backend по общему CHATBALLS_CALL_TURN_SECRET
|
||||
# (static-auth-secret); адреса relay backend строит от адреса установки.
|
||||
coturn:
|
||||
profiles: ["calls"]
|
||||
image: ${CHATBALLS_COTURN_IMAGE:-coturn/coturn:4.6}
|
||||
restart: unless-stopped
|
||||
network_mode: host
|
||||
@@ -299,40 +307,31 @@ services:
|
||||
# старт, человек его не вводит и не дублирует в двух местах.
|
||||
- -c
|
||||
- /run/chatballs/secrets/turnserver-secret.conf
|
||||
- --realm=${CHATBALLS_CALL_TURN_REALM:-}
|
||||
# Весь TURN живёт на выделенном публичном IP (отдельный порт/NIC), Caddy — на
|
||||
# основном IP. Это освобождает 443 под TURN-over-TLS без конфликта с web.
|
||||
- --listening-ip=${CHATBALLS_TURN_LISTENING_IP:-0.0.0.0}
|
||||
- --relay-ip=${CHATBALLS_TURN_EXTERNAL_IP:-}
|
||||
- --external-ip=${CHATBALLS_TURN_EXTERNAL_IP:-}
|
||||
- --listening-port=${CHATBALLS_TURN_LISTENING_PORT:-3478}
|
||||
# TURN-over-TLS на 443: проходит через VPN/строгие сети, где UDP и 3478 режут.
|
||||
- --tls-listening-port=${CHATBALLS_TURN_TLS_PORT:-443}
|
||||
- --cert=/etc/coturn/certs/fullchain.pem
|
||||
- --pkey=/etc/coturn/certs/privkey.pem
|
||||
- --min-port=${CHATBALLS_TURN_MIN_PORT:-49160}
|
||||
# realm участвует только в digest-аутентификации; клиент узнаёт его от
|
||||
# самого сервера, поэтому значение фиксированное.
|
||||
- --realm=chatballs
|
||||
- --listening-port=3478
|
||||
# TLS и DTLS выключены: без сертификата они всё равно не поднялись бы, а
|
||||
# сертификат означает 443 и выделенный адрес.
|
||||
- --no-tls
|
||||
- --no-dtls
|
||||
- --min-port=49160
|
||||
# Relay-диапазон UDP (1 порт на media-endpoint; ~2 порта на звонок).
|
||||
# 49160-49999 = 840 портов ≈ 420 одновременных TURN-звонков на хосте.
|
||||
# Расширяется через CHATBALLS_TURN_MIN_PORT/CHATBALLS_TURN_MAX_PORT; все порты диапазона
|
||||
# должны быть открыты на firewall и не пересекаться с ephemeral-диапазоном ОС.
|
||||
- --max-port=${CHATBALLS_TURN_MAX_PORT:-49999}
|
||||
# Все порты диапазона должны быть открыты на firewall и не пересекаться
|
||||
# с ephemeral-диапазоном ОС.
|
||||
- --max-port=49999
|
||||
# Запрет анонимного и внутрисетевого relay (SPEC §11: без пересечения с хостом).
|
||||
- --no-multicast-peers
|
||||
- --no-tcp-relay
|
||||
- --denied-peer-ip=10.0.0.0-10.255.255.255
|
||||
- --denied-peer-ip=172.16.0.0-172.31.255.255
|
||||
- --denied-peer-ip=192.168.0.0-192.168.255.255
|
||||
- --no-tlsv1
|
||||
- --no-tlsv1_1
|
||||
volumes:
|
||||
# LE-сертификат TURN-хоста (turns:) кладёт на хост renewal deploy-hook —
|
||||
# это единственное место, где стек смотрит наружу файлом, и только при
|
||||
# включённом профиле calls. Каталог задаётся явно.
|
||||
- chatballs-secrets:/run/chatballs/secrets:ro
|
||||
- ${CHATBALLS_TURN_CERTS_DIR:-./data/coturn-certs}:/etc/coturn/certs:ro
|
||||
healthcheck:
|
||||
# Allocation smoke: STUN binding к собственному listener на выделенном IP.
|
||||
test: ["CMD", "turnutils_stunclient", "-p", "${CHATBALLS_TURN_LISTENING_PORT:-3478}", "${CHATBALLS_TURN_LISTENING_IP:-0.0.0.0}"]
|
||||
# Allocation smoke: STUN binding к собственному listener.
|
||||
test: ["CMD", "turnutils_stunclient", "-p", "3478", "127.0.0.1"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
@@ -97,27 +97,6 @@ verify_release_checksums() {
|
||||
}
|
||||
}
|
||||
|
||||
validate_calls_network_boundary() {
|
||||
# Профиль calls включают переменной окружения — тем же способом, каким её
|
||||
# читает сам compose. Файла с конфигурацией у установки нет.
|
||||
local web_ip turn_ip
|
||||
web_ip="${CHATBALLS_WEB_LISTENING_IP:-}"
|
||||
turn_ip="${CHATBALLS_TURN_LISTENING_IP:-}"
|
||||
|
||||
[[ -n "$web_ip" ]] || {
|
||||
log_err "CHATBALLS_WEB_LISTENING_IP is required for calls profile"
|
||||
return 1
|
||||
}
|
||||
[[ "$web_ip" != "0.0.0.0" ]] || {
|
||||
log_err "CHATBALLS_WEB_LISTENING_IP cannot be 0.0.0.0 when calls profile uses TURN TLS on 443"
|
||||
return 1
|
||||
}
|
||||
[[ "$web_ip" != "$turn_ip" ]] || {
|
||||
log_err "web and TURN listeners must use different public IP addresses"
|
||||
return 1
|
||||
}
|
||||
}
|
||||
|
||||
release_image_keys() {
|
||||
printf '%s\n' \
|
||||
CHATBALLS_BACKEND_IMAGE \
|
||||
|
||||
@@ -24,10 +24,7 @@ cmd_deploy() {
|
||||
run_compose run --rm init || die "deploy: init (migrate) failed" 1
|
||||
|
||||
log "deploy: starting application services"
|
||||
local app_services=(backend-app backend-platform backend-admin worker frontend gateway)
|
||||
if profile_enabled calls; then
|
||||
app_services+=(coturn)
|
||||
fi
|
||||
local app_services=(backend-app backend-platform backend-admin worker frontend gateway coturn)
|
||||
run_compose up -d "${app_services[@]}" || die "deploy: application start failed" 1
|
||||
|
||||
log "deploy: waiting for application health"
|
||||
@@ -36,9 +33,8 @@ cmd_deploy() {
|
||||
_wait_running backend-admin 30 || die "deploy: admin backend did not start" 1
|
||||
_wait_running frontend 30 || die "deploy: frontend did not start" 1
|
||||
_wait_running gateway 30 || die "deploy: gateway did not start" 1
|
||||
if profile_enabled calls; then
|
||||
_wait_healthy coturn 60 || die "deploy: coturn did not become healthy" 1
|
||||
fi
|
||||
# Relay поднимается вместе со всеми: звонки за NAT без него не соединяются.
|
||||
_wait_healthy coturn 60 || die "deploy: coturn did not become healthy" 1
|
||||
|
||||
log "deploy: running smoke checks"
|
||||
_smoke || die "deploy: smoke checks failed" 1
|
||||
@@ -57,10 +53,6 @@ _deploy_validate() {
|
||||
# Домены здесь не проверяются: установка отвечает по адресу сервера, а свой
|
||||
# домен владелец задаёт в «Настройках» — снаружи его знать неоткуда.
|
||||
|
||||
if profile_enabled calls; then
|
||||
validate_calls_network_boundary || return 1
|
||||
fi
|
||||
|
||||
compose_config_validate >/dev/null 2>&1 || {
|
||||
log_err "compose config invalid"
|
||||
return 1
|
||||
|
||||
@@ -85,18 +85,14 @@ cmd_doctor() {
|
||||
# прежние проверки просто ругались на исправную установку — они читали
|
||||
# instance .env, которого у продукта нет.
|
||||
|
||||
if profile_enabled calls; then
|
||||
local missing=0 k
|
||||
for k in CHATBALLS_CALL_TURN_REALM CHATBALLS_TURN_EXTERNAL_IP CHATBALLS_TURN_LISTENING_IP; do
|
||||
if [[ -z "${!k:-}" ]]; then
|
||||
_doctor_report 0 "$k required for calls profile"
|
||||
missing=1
|
||||
fi
|
||||
done
|
||||
if [[ "$missing" == "0" ]] && validate_calls_network_boundary; then
|
||||
_doctor_report 1 "calls profile network boundary valid"
|
||||
# Relay стоит на том же адресе, что и веб: порт 3478 не спорит с 80 и 443,
|
||||
# поэтому проверять нечего, кроме того, что он поднят вместе со стеком.
|
||||
# На неподнятом стеке проверять нечего вовсе — doctor запускают и до него.
|
||||
if run_compose ps --status running --services 2>/dev/null | grep -qx backend-app; then
|
||||
if run_compose ps --status running --services 2>/dev/null | grep -qx coturn; then
|
||||
_doctor_report 1 "calls relay (coturn) is running"
|
||||
else
|
||||
_doctor_report 0 "calls profile network boundary invalid"
|
||||
_doctor_report 0 "calls relay (coturn) is not running — calls behind NAT will fail"
|
||||
fi
|
||||
fi
|
||||
|
||||
|
||||
@@ -118,7 +118,7 @@ support.js — рантайм макетов
|
||||
- **KB5 Редактор знания** — тот же полноэкранный split, что PT7: слева заголовок + панель Markdown + монополе + статусная строка (знаки, ≈фрагменты, автосохранение); в центре предпросмотр «внутренний материал» и «Как ответит агент»; справа рейка 300px: категория, краткое описание, тумблер ответов, вложения (drop-зона + список), агенты (только чтение). Primary — «Сохранить и переиндексировать».
|
||||
- **KB6 Редактор · перетаскивание файла** — drop-оверлей «Отпустите, чтобы приложить к знанию» (файл = вложение + ссылка в тексте), в рейке загрузка с прогрессом, в шапке бейдж «Есть несохранённые правки».
|
||||
- **KB7 Категории** — дерево строками с ручкой перетаскивания, счётчиками, инлайн-переименованием, «+ вложенная»; удаление недоступно у системной и непустой категории (тайтл объясняет причину), справа карточка правил и предупреждение о непустой категории.
|
||||
- **KB8 Импорт YAML** — разбор до применения: файл, сводка «создать 4 / обновить 2 / ошибка 1», таблица документ · `categoryPath` · действие · замечание; ошибочная строка красная с текстом «категории нет — импорт не создаёт категории»; primary «Импортировать 6 документов».
|
||||
- **KB8 Импорт YAML** — разбор до применения: файл, сводка «создать 5 / обновить 2», таблица документ · `categoryPath` · действие · замечание; недостающие уровни категорий создаются автоматически с пояснением в замечании; primary «Импортировать 7 документов».
|
||||
- **KB9 Пустая категория** — «В категории «Обучение» пока нет знаний» + «Добавить знание» / «Импорт YAML»; счётчики не подменяются нулями из другой категории.
|
||||
|
||||
### Решения по базе знаний
|
||||
|
||||
@@ -580,9 +580,9 @@ a:hover{opacity:0.85;}
|
||||
</div>
|
||||
|
||||
<div style="display:flex;align-items:center;gap:12px;">
|
||||
<span style="flex:1;font-size:12px;line-height:1.5;color:var(--c-n4);max-width:640px;">Импорт не создаёт категории неявно: документ с несуществующим <code style="font-family:ui-monospace,SFMono-Regular,Menlo,monospace;">categoryPath</code> отклоняется отдельно, остальные применяются. Без указания пути документ попадает в «Без категории».</span>
|
||||
<span style="flex:1;font-size:12px;line-height:1.5;color:var(--c-n4);max-width:640px;">Недостающие категории из файла создаются автоматически. Без указания пути новый документ попадает в «Без категории», существующий сохраняет свою категорию.</span>
|
||||
<button style="height:34px;padding:0 13px;border:1px solid var(--c-n7);border-radius:8px;background:var(--c-surface);color:var(--c-n2);font-size:13px;font-weight:500;cursor:pointer;font-family:inherit;">Отмена</button>
|
||||
<button style="height:34px;padding:0 15px;border:none;border-radius:8px;background:var(--c-accent);color:#fff;font-size:13px;font-weight:600;cursor:pointer;font-family:inherit;" style-hover="background:var(--c-accent-hover);">Импортировать 6 документов</button>
|
||||
<button style="height:34px;padding:0 15px;border:none;border-radius:8px;background:var(--c-accent);color:#fff;font-size:13px;font-weight:600;cursor:pointer;font-family:inherit;" style-hover="background:var(--c-accent-hover);">Импортировать 7 документов</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -831,9 +831,8 @@ class Component extends DCLogic {
|
||||
}));
|
||||
|
||||
const importStats = [
|
||||
{ label: "создать 4", bg: "var(--c-ok-bg)", color: "var(--c-ok-text)" },
|
||||
{ label: "создать 5", bg: "var(--c-ok-bg)", color: "var(--c-ok-text)" },
|
||||
{ label: "обновить 2", bg: "var(--c-accent-bg)", color: "var(--c-accent-text)" },
|
||||
{ label: "ошибка 1", bg: "var(--c-err-bg)", color: "var(--c-err-text)" },
|
||||
];
|
||||
const IA = {
|
||||
new: { action: "создать", actBg: "var(--c-ok-bg)", actColor: "var(--c-ok-text)" },
|
||||
@@ -844,7 +843,7 @@ class Component extends DCLogic {
|
||||
{ title: "srok-poshiva", path: "Регламенты / Пошив", ...IA.upd, note: "совпал заголовок — заменим текст и пересоберём фрагменты" },
|
||||
{ title: "perenos-primerki", path: "Регламенты / Пошив", ...IA.new, note: "" },
|
||||
{ title: "price-2026", path: "Прайсы и оплата", ...IA.upd, note: "категория в файле совпадает с текущей" },
|
||||
{ title: "foxray-licensing", path: "Продукты / FoxRay / Лицензирование", ...IA.err, note: "категории «Лицензирование» нет — импорт не создаёт категории. Создайте её или уберите последний уровень пути.", err: true },
|
||||
{ title: "foxray-licensing", path: "Продукты / FoxRay / Лицензирование", ...IA.new, note: "Недостающие категории будут созданы автоматически." },
|
||||
{ title: "foxray-update", path: "Продукты / FoxRay", ...IA.new, note: "" },
|
||||
{ title: "uhod-za-shelkom", path: "не указана", ...IA.new, note: "попадёт в «Без категории»" },
|
||||
{ title: "script-otkaz", path: "Скрипты общения", ...IA.new, note: "" },
|
||||
|
||||
@@ -5,3 +5,4 @@ export { type AudioBarKind, type AudioCallMode, type AudioCallStatus, buildAudio
|
||||
export { useCallRtcSession, type CallMediaIssue } from "./call/useCallRtcSession";
|
||||
export { useAudioCue, useLoopingAudio } from "./audio/useAudio";
|
||||
export { Loader } from "./loader/Loader";
|
||||
export { VOICE_WAVE_BARS, voiceWaveHeights } from "./voice/voiceWave";
|
||||
@@ -0,0 +1,18 @@
|
||||
// Волна голосового сообщения.
|
||||
//
|
||||
// Реальная амплитуда записи не хранится, поэтому рисунок считается из
|
||||
// идентификатора сообщения: он стабилен между рендерами и одинаков в чате
|
||||
// оператора и в виджете клиента — одно и то же сообщение выглядит одинаково с
|
||||
// обеих сторон разговора.
|
||||
|
||||
export const VOICE_WAVE_BARS = 32;
|
||||
|
||||
export function voiceWaveHeights(seed: number, bars = VOICE_WAVE_BARS): 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;
|
||||
}
|
||||
@@ -133,23 +133,10 @@ def test_deploy_success_orders_canonical_workflow(fake_env):
|
||||
assert not (fake_env.instance / "compose.yaml").exists()
|
||||
|
||||
|
||||
def test_deploy_includes_coturn_when_calls_profile_active(fake_env):
|
||||
def test_deploy_starts_the_calls_relay_with_everything_else(fake_env):
|
||||
|
||||
fake_env.set_env(
|
||||
|
||||
COMPOSE_PROFILES="calls",
|
||||
|
||||
CHATBALLS_WEB_LISTENING_IP="203.0.113.10",
|
||||
|
||||
CHATBALLS_CALL_TURN_SECRET="turn-secret",
|
||||
|
||||
CHATBALLS_CALL_TURN_REALM="turn.hub.test",
|
||||
|
||||
CHATBALLS_TURN_EXTERNAL_IP="203.0.113.11",
|
||||
|
||||
CHATBALLS_TURN_LISTENING_IP="203.0.113.11",
|
||||
|
||||
)
|
||||
# Relay поднимается всегда: звонок за симметричным NAT без него не
|
||||
# соединяется, и отдельным профилем это быть не должно.
|
||||
|
||||
fake_env.install_docker()
|
||||
|
||||
@@ -160,7 +147,7 @@ def test_deploy_includes_coturn_when_calls_profile_active(fake_env):
|
||||
|
||||
assert r.returncode == 0, r.stderr
|
||||
|
||||
joined = "\n".join(_log_lines(fake_env))
|
||||
joined = chr(10).join(_log_lines(fake_env))
|
||||
|
||||
assert (
|
||||
|
||||
@@ -171,39 +158,6 @@ def test_deploy_includes_coturn_when_calls_profile_active(fake_env):
|
||||
)
|
||||
|
||||
|
||||
def test_deploy_rejects_shared_web_and_turn_ip(fake_env):
|
||||
|
||||
fake_env.set_env(
|
||||
|
||||
COMPOSE_PROFILES="calls",
|
||||
|
||||
CHATBALLS_WEB_LISTENING_IP="203.0.113.10",
|
||||
|
||||
CHATBALLS_CALL_TURN_SECRET="turn-secret",
|
||||
|
||||
CHATBALLS_CALL_TURN_REALM="turn.hub.test",
|
||||
|
||||
CHATBALLS_TURN_EXTERNAL_IP="203.0.113.10",
|
||||
|
||||
CHATBALLS_TURN_LISTENING_IP="203.0.113.10",
|
||||
|
||||
)
|
||||
|
||||
fake_env.install_docker()
|
||||
|
||||
fake_env.install_flock(held=False)
|
||||
|
||||
|
||||
result = _run(fake_env, "deploy", "--non-interactive")
|
||||
|
||||
|
||||
assert result.returncode != 0
|
||||
|
||||
assert "different public IP" in result.stderr
|
||||
|
||||
assert " pull" not in "\n".join(_log_lines(fake_env))
|
||||
|
||||
|
||||
def test_deploy_fails_when_release_env_missing(fake_env):
|
||||
|
||||
|
||||
|
||||
@@ -6,9 +6,8 @@
|
||||
стек падал на первом старте, уже у человека.
|
||||
|
||||
Эти тесты держат свойство, а не текущий текст файла: в production-манифесте нет
|
||||
ни одного bind-mount (кроме сертификатов TURN у опционального профиля calls и
|
||||
Docker-сокета у сервиса обновлений), а все ссылки на образы поддаются
|
||||
закреплению по digest.
|
||||
ни одного bind-mount (кроме Docker-сокета у сервиса обновлений), а все ссылки на
|
||||
образы поддаются закреплению по digest.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -24,10 +23,9 @@ REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||
COMPOSE = REPO_ROOT / "compose.yaml"
|
||||
PIN_SCRIPT = REPO_ROOT / "scripts" / "pin-release-compose.py"
|
||||
|
||||
# Исключения: сертификат TURN-хоста кладёт на хост renewal-хук (только при
|
||||
# включённом профиле calls), а сервис обновлений управляет стеком через
|
||||
# Docker-сокет хоста — это его назначение (ADR-CHATBALLS-0049).
|
||||
BIND_MOUNT_EXCEPTIONS = {"coturn", "updater"}
|
||||
# Исключение одно: сервис обновлений управляет стеком через Docker-сокет
|
||||
# хоста — это его назначение (ADR-CHATBALLS-0049).
|
||||
BIND_MOUNT_EXCEPTIONS = {"updater"}
|
||||
|
||||
DIGEST = "sha256:" + "a" * 64
|
||||
IMAGE_KEYS = (
|
||||
|
||||
Reference in new issue
Block a user