Compare commits

...
Author SHA1 Message Date
Andrey df9c6ad6fd 🚨 fix(lint): порядок импортов портала 2026-09-24 14:08:04 +03:00
Andrey 8c618691f6 ✨ feat(portals): навигация и связанные статьи 2026-09-24 14:05:21 +03:00
AndreyandClaude Opus 5.5 f57b1ee54a 🔖 release: 1.15.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 10:05:44 +03:00
AndreyandClaude Opus 5.5 64266dcc9c ✨ feat(settings): шаблоны ответов в настройках и переменные в тексте
Раздел «Настройки → Шаблоны ответов»: список по образцу «Групп», создание
кнопкой в шапке раздела, правка и удаление окнами, как у интеграций. Без права
на настройки список только читается.

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

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

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

Удаление используемого провайдера возвращает 409 с понятной причиной и не пишет ложное событие об успешном удалении. Добавлены миграция и регрессионные тесты.
2026-09-22 07:13:20 +03:00
AndreyandClaude Opus 5 4e56b10e6a 🔖 release: 1.14.1
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-20 10:49:00 +03:00
AndreyandClaude Opus 5 bcb66fadbc 🐛 fix(webchat): первое открытие чата — с анимацией и кнопкой «свернуть»
Первый кадр силуэта рисовался раньше, чем создавался слой, на котором он
живёт. Обращение к нему падало, и остаток обработчика не выполнялся: окно
появлялось рывком, а кнопка оставалась со знаком агента вместо шеврона —
свернуть чат было нечем. Со второго раза слой уже существовал, и всё
работало, поэтому дефект был виден только при первом открытии.

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-20 10:49:00 +03:00
AndreyandClaude Opus 5 2ffad1e787 🔖 release: 1.14.0
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-20 10:36:10 +03:00
AndreyandClaude Opus 5 c69cd1b436 🔥 chore(resources): убрать неиспользуемые файлы
Иконка приложения и два hero-изображения ни на что не ссылались: ни сборка,
ни README, ни документация их не читают. Удалены владельцем.

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-20 10:35:00 +03:00
106 changed files with 4054 additions and 677 deletions

No files matched your search

+1 -1
View File
@@ -1 +1 @@
1.13.0
1.15.0
+218 -44
View File
@@ -1,4 +1,20 @@
"""Обращения к LLM-провайдеру: подготовка, сам вызов и запись в журнал.
Вызов провайдера ждёт ответа десятки секунд, а подготовка и журнал — это
обращения к базе. В одной функции они означают открытую транзакцию на всё время
ожидания, а вместе с ней занятое соединение из пула и RLS-контекст
(chatballs.tenancy.middleware). Поэтому шаги разделены: `prepare_*` и `record_*`
вызывают внутри транзакции, `run_*` — вне её.
`invoke_chat` и `embed_texts` остаются для мест, где ждать под транзакцией не
жалко: индексация знаний, предпросмотр карточки агента, тесты. Ход диалога с
клиентом ходит по шагам (chatballs.ai.turn).
"""
from __future__ import annotations
import time
from dataclasses import dataclass
from django.conf import settings
@@ -15,19 +31,141 @@ from chatballs.ai.provider.base import (
from chatballs.ai.provider.factory import get_provider
from chatballs.ai.provider.resilience import CircuitBreaker, call_with_resilience
_breaker = CircuitBreaker()
# Предохранитель считает сбои по ключу «организация + интеграция»: провайдер у
# каждой организации свой, и отозванный ключ одной не имеет отношения к AI
# остальных. Общий на процесс предохранитель гасил AI у всех сразу.
@dataclass(slots=True)
class _BreakerSlot:
revision: int
breaker: CircuitBreaker
def _prepare_invocation(*, channel, requested_model: str | None) -> tuple[LLMProvider, str]:
_breakers: dict[tuple[int, int], _BreakerSlot] = {}
def _breaker(key: tuple[int, int], revision: int) -> CircuitBreaker:
slot = _breakers.get(key)
if slot is None or slot.revision != revision:
slot = _BreakerSlot(revision=revision, breaker=CircuitBreaker())
_breakers[key] = slot
return slot.breaker
def reset_breakers() -> None:
"""Для тестов: забыть накопленные сбои провайдеров."""
_breakers.clear()
def _breaker_identity(channel) -> tuple[tuple[int, int], int]:
"""Ключ предохранителя. Без канала провайдер может быть только тестовым —
считать сбои там не по чему, и общий ключ (0, 0) никому не мешает."""
if channel is None:
return (0, 0), 0
integration_id, revision = routing.integration_runtime_identity(channel)
return (channel.organization_id, integration_id), revision
def _elapsed_ms(started: float) -> int:
return int((time.monotonic() - started) * 1000)
@dataclass(frozen=True, slots=True)
class ChatJob:
"""Всё для похода к модели, уже прочитанное из базы."""
provider: LLMProvider
model: str
messages: list[ChatMessage]
breaker_key: tuple[int, int]
breaker_revision: int
params: dict | None = None
@dataclass(frozen=True, slots=True)
class EmbeddingJob:
"""То же для эмбеддингов: вектор считается тем же провайдером организации."""
provider: LLMProvider
model: str
texts: list[str]
breaker_key: tuple[int, int]
breaker_revision: int
def _effective_model(channel, requested_model: str | None) -> str:
# BYOK — единственный режим (ADR-CHATBALLS-0042 §3): модель берётся из интеграции
# организации с fallback на модель агента. Без интеграции модель остаётся
# агентской: тестовый провайдер работает, прод упадёт в get_provider штатно.
agent = channel.ai_agent
agent = getattr(channel, "ai_agent", None)
fallback = str(getattr(agent, "model", "") or "")
if requested_model:
return requested_model
try:
effective_model = routing.resolve_model(channel, fallback_model=agent.model)
return routing.resolve_model(channel, fallback_model=fallback)
except routing.IntegrationNotConfigured:
effective_model = agent.model
return get_provider(channel=channel), requested_model or effective_model
return fallback
def prepare_chat(
*,
channel,
messages: list[ChatMessage],
model: str | None = None,
params: dict | None = None,
timeout: float | None = None,
) -> ChatJob:
"""Шаг в транзакции: провайдер, модель и очищенный от ПДн текст запроса."""
breaker_key, breaker_revision = _breaker_identity(channel)
return ChatJob(
provider=get_provider(channel=channel, timeout=timeout),
model=_effective_model(channel, model),
messages=[ChatMessage(role=item.role, content=redact(item.content)) for item in messages],
breaker_key=breaker_key,
breaker_revision=breaker_revision,
params=params,
)
def run_chat(job: ChatJob) -> ChatResult:
"""Шаг без транзакции: обращение к провайдеру."""
return call_with_resilience(
lambda: job.provider.chat(messages=job.messages, model=job.model, params=job.params),
retries=settings.CHATBALLS_AI_MAX_RETRIES,
breaker=_breaker(job.breaker_key, job.breaker_revision),
)
def record_chat(
*,
channel,
job: ChatJob,
purpose: str,
result: ChatResult | None = None,
error: Exception | None = None,
latency_ms: int = 0,
used_fragment_ids: list | None = None,
) -> None:
"""Шаг в транзакции: строка журнала вызовов — и об успехе, и об отказе."""
LlmInvocation.objects.create(
organization=channel.organization,
channel=channel,
purpose=purpose,
operation="chat",
model=result.model if result is not None else job.model,
prompt_tokens=result.prompt_tokens if result else 0,
completion_tokens=result.completion_tokens if result else 0,
total_tokens=result.total_tokens if result else 0,
latency_ms=latency_ms,
status=LlmInvocationStatus.SUCCESS if result is not None else LlmInvocationStatus.ERROR,
error="" if error is None else str(error)[:1000],
used_fragment_ids=used_fragment_ids or [],
)
def invoke_chat(
@@ -39,44 +177,84 @@ def invoke_chat(
params: dict | None = None,
used_fragment_ids: list | None = None,
) -> ChatResult:
provider, model = _prepare_invocation(channel=channel, requested_model=model)
"""Три шага подряд, в транзакции вызывающего: там, где ждать не жалко."""
safe_messages = [ChatMessage(role=item.role, content=redact(item.content)) for item in messages]
job = prepare_chat(channel=channel, messages=messages, model=model, params=params)
started = time.monotonic()
try:
result: ChatResult = call_with_resilience(
lambda: provider.chat(messages=safe_messages, model=model, params=params),
retries=settings.CHATBALLS_AI_MAX_RETRIES,
breaker=_breaker,
)
result = run_chat(job)
except ProviderError as error:
LlmInvocation.objects.create(
organization=channel.organization,
record_chat(
channel=channel,
job=job,
purpose=purpose,
operation="chat",
model=model,
status=LlmInvocationStatus.ERROR,
error=str(error)[:1000],
latency_ms=int((time.monotonic() - started) * 1000),
error=error,
latency_ms=_elapsed_ms(started),
)
raise
record_chat(
channel=channel,
job=job,
purpose=purpose,
result=result,
latency_ms=_elapsed_ms(started),
used_fragment_ids=used_fragment_ids,
)
return result
return_result = result
def prepare_embedding(
*,
channel,
texts: list[str],
model: str,
timeout: float | None = None,
) -> EmbeddingJob:
"""Шаг в транзакции: провайдер эмбеддингов организации."""
breaker_key, breaker_revision = _breaker_identity(channel)
return EmbeddingJob(
provider=get_provider(channel=channel, timeout=timeout),
model=model,
texts=texts,
breaker_key=breaker_key,
breaker_revision=breaker_revision,
)
def run_embedding(job: EmbeddingJob) -> list[EmbeddingResult]:
"""Шаг без транзакции: обращение к провайдеру."""
return call_with_resilience(
lambda: job.provider.embed(texts=job.texts, model=job.model),
retries=settings.CHATBALLS_AI_MAX_RETRIES,
breaker=_breaker(job.breaker_key, job.breaker_revision),
)
def record_embedding(
*,
channel=None,
organization=None,
model: str,
purpose: str,
results: list[EmbeddingResult],
latency_ms: int = 0,
) -> None:
"""Шаг в транзакции: строка журнала."""
tokens = sum(result.tokens for result in results)
LlmInvocation.objects.create(
organization=channel.organization,
organization=channel.organization if channel else organization,
channel=channel,
purpose=purpose,
operation="chat",
model=result.model,
prompt_tokens=result.prompt_tokens,
completion_tokens=result.completion_tokens,
total_tokens=result.total_tokens,
latency_ms=int((time.monotonic() - started) * 1000),
operation="embedding",
model=model,
prompt_tokens=tokens,
total_tokens=tokens,
latency_ms=latency_ms,
status=LlmInvocationStatus.SUCCESS,
used_fragment_ids=used_fragment_ids or [],
)
return return_result
def embed_texts(
@@ -87,21 +265,17 @@ def embed_texts(
model: str,
purpose: str = "retrieval",
) -> list[EmbeddingResult]:
provider = get_provider(channel=channel)
results: list[EmbeddingResult] = call_with_resilience(
lambda: provider.embed(texts=texts, model=model),
retries=settings.CHATBALLS_AI_MAX_RETRIES,
breaker=_breaker,
)
tokens = sum(result.tokens for result in results)
LlmInvocation.objects.create(
organization=channel.organization if channel else organization,
"""Три шага подряд: индексация знаний и прочие неинтерактивные места."""
job = prepare_embedding(channel=channel, texts=texts, model=model)
started = time.monotonic()
results = run_embedding(job)
record_embedding(
channel=channel,
purpose=purpose,
operation="embedding",
organization=organization,
model=model,
prompt_tokens=tokens,
total_tokens=tokens,
status=LlmInvocationStatus.SUCCESS,
purpose=purpose,
results=results,
latency_ms=_elapsed_ms(started),
)
return results
@@ -35,6 +35,16 @@ class ProviderError(Exception):
"""Transient/technical provider failure (eligible for retry / circuit breaker)."""
class ProviderRejected(ProviderError):
"""Отказ, который повтором не лечится: провайдер не принял сам запрос.
Неверный ключ, несуществующая модель, слишком длинный контекст. Повтор
потратит ещё один таймаут и получит тот же ответ, а клиент всё это время
ждёт ответа. «Слишком часто» (429) сюда не относится — это как раз тот
случай, когда повторить стоит.
"""
class LLMProvider(abc.ABC):
name: str = "base"
@@ -13,7 +13,7 @@ def _test_provider() -> LLMProvider:
return LocalProvider()
def get_provider(*, channel=None) -> LLMProvider:
def get_provider(*, channel=None, timeout: float | None = None) -> LLMProvider:
"""Resolve the organization's own provider (BYOK, ADR-CHATBALLS-0042 §3).
The test adapter is an explicit test-surface override. Managed platform
@@ -29,10 +29,10 @@ def get_provider(*, channel=None) -> LLMProvider:
raise ProviderError(
t("ai.provider_not_configured")
)
return routing.resolve_provider(channel)
return routing.resolve_provider(channel, timeout=timeout)
def get_transcription_provider(*, channel=None) -> LLMProvider:
def get_transcription_provider(*, channel=None, timeout: float | None = None) -> LLMProvider:
"""Провайдер расшифровки голосовых.
Отличается от `get_provider` одним: агент может расшифровывать другим
@@ -42,4 +42,4 @@ def get_transcription_provider(*, channel=None) -> LLMProvider:
return _test_provider()
if channel is None:
raise ProviderError(t("ai.provider_not_configured"))
return routing.resolve_transcription_provider(channel)
return routing.resolve_transcription_provider(channel, timeout=timeout)
@@ -37,7 +37,13 @@ import json
import urllib.error
import urllib.request
from chatballs.ai.provider.base import ChatMessage, ChatResult, EmbeddingResult, ProviderError
from chatballs.ai.provider.base import (
ChatMessage,
ChatResult,
EmbeddingResult,
ProviderError,
ProviderRejected,
)
from chatballs.i18n import t
from chatballs.integrations.proxy import build_opener
@@ -72,6 +78,20 @@ def post_json(*, base_url: str, path: str, api_key: str, payload: dict, timeout:
return json.loads(response.read().decode("utf-8"))
# Отказ самого провайдера разбирается отдельно: 4xx (кроме 429) — это ключ,
# модель или размер запроса, и повтор даст тот же ответ через ещё один таймаут.
except urllib.error.HTTPError as error:
detail = error.read().decode("utf-8", "replace")[:300]
if error.code != 429 and 400 <= error.code < 500:
raise ProviderRejected(f"HTTP {error.code}: {detail}") from error
raise ProviderError(f"HTTP {error.code}: {detail}") from error
# http.client.HTTPException covers IncompleteRead/BadStatusLine (dropped reply)
# — those are not OSError, so they would slip past ProviderError otherwise.
@@ -1,7 +1,7 @@
import time
from collections.abc import Callable
from chatballs.ai.provider.base import ProviderError
from chatballs.ai.provider.base import ProviderError, ProviderRejected
class CircuitBreakerOpen(ProviderError):
@@ -38,17 +38,21 @@ def call_with_resilience(
sleep: Callable[[float], None] = time.sleep,
backoff: float = 0.5,
):
if breaker is not None:
breaker.before()
attempt = 0
while True:
if breaker is not None:
breaker.before()
try:
result = func()
except ProviderRejected:
# Провайдер отказал по существу запроса: повторять нечего, и
# предохранитель тут ни при чём — сам провайдер жив и отвечает.
raise
except ProviderError:
if breaker is not None:
breaker.on_failure()
attempt += 1
if attempt > retries:
if breaker is not None:
breaker.on_failure()
raise
sleep(backoff * attempt)
continue
+37 -8
View File
@@ -40,10 +40,14 @@ class IntegrationNotConfigured(ProviderError):
"""
def resolve_provider(channel) -> LLMProvider:
"""Build the BYOK LLMProvider from the channel's explicit integration."""
def resolve_provider(channel, *, timeout: float | None = None) -> LLMProvider:
"""Build the BYOK LLMProvider from the channel's explicit integration.
`timeout` переопределяет срок ожидания ответа: интерактивному ходу диалога
отведено меньше, чем индексации знаний (chatballs.ai.turn).
"""
integration = _channel_integration(channel)
return _provider_from_integration(integration)
return _provider_from_integration(integration, timeout=timeout)
def resolve_provider_and_model(channel, *, fallback_model: str) -> tuple[LLMProvider, str]:
@@ -90,9 +94,9 @@ def _transcription_integration(channel) -> Integration:
return integration
def resolve_transcription_provider(channel) -> LLMProvider:
def resolve_transcription_provider(channel, *, timeout: float | None = None) -> LLMProvider:
"""Провайдер расшифровки: отдельная интеграция агента либо провайдер ответов."""
return _provider_from_integration(_transcription_integration(channel))
return _provider_from_integration(_transcription_integration(channel), timeout=timeout)
def resolve_transcription_model(channel) -> str:
@@ -120,14 +124,39 @@ def _channel_integration(channel) -> Integration:
return integration
def _provider_from_integration(integration: Integration) -> LLMProvider:
def integration_id(channel) -> int:
"""Идентификатор интеграции канала; 0 — интеграции нет.
Нужен там, где интеграция — ключ, а не источник настроек: предохранитель
считает сбои по конкретному ключу организации (chatballs.ai.invocation).
"""
try:
return _channel_integration(channel).id
except IntegrationNotConfigured:
return 0
def integration_runtime_identity(channel) -> tuple[int, int]:
"""Идентификатор и версия runtime-настроек выбранного провайдера."""
try:
integration = _channel_integration(channel)
except IntegrationNotConfigured:
return 0, 0
return integration.id, integration.runtime_revision
def _provider_from_integration(
integration: Integration, *, timeout: float | None = None
) -> LLMProvider:
from django.conf import settings
wait = timeout or settings.CHATBALLS_AI_REQUEST_TIMEOUT
if integration.provider == IntegrationProvider.OPENROUTER:
return OpenRouterProvider(
api_key=integration.secret,
base_url=integration.config.get("base_url") or settings.CHATBALLS_OPENROUTER_BASE_URL,
timeout=settings.CHATBALLS_AI_REQUEST_TIMEOUT,
timeout=wait,
proxy_url=integration.config.get("proxy_url", ""),
)
if integration.provider == IntegrationProvider.DEMO:
@@ -136,7 +165,7 @@ def _provider_from_integration(integration: Integration) -> LLMProvider:
return CustomProvider(
api_key=integration.secret,
base_url=integration.config["base_url"],
timeout=settings.CHATBALLS_AI_REQUEST_TIMEOUT,
timeout=wait,
proxy_url=integration.config.get("proxy_url", ""),
)
raise IntegrationNotConfigured(
+22 -6
View File
@@ -43,6 +43,26 @@ def semantic_search(
)
def merge_hits(
agent: AIAgent,
query: str,
query_vector: list[float] | None,
*,
limit: int = 5,
) -> list[KnowledgeFragment]:
"""Оба поиска и их склейка — шаг в транзакции, без обращений наружу.
Вектор считается отдельно (chatballs.ai.turn): поход за эмбеддингом — это
сеть, и держать ради него транзакцию незачем. Без вектора остаётся
лексический поиск: знания находятся хуже, но находятся.
"""
semantic = semantic_search(agent, query_vector, limit=limit) if query_vector else []
lexical = lexical_search(agent, query, limit=limit)
seen = {fragment.id for fragment in semantic}
merged = semantic + [fragment for fragment in lexical if fragment.id not in seen]
return merged[:limit]
class KnowledgeRetriever:
"""Hybrid retriever: semantic (pgvector) primary, lexical (Postgres FTS) complementary."""
@@ -56,10 +76,6 @@ class KnowledgeRetriever:
model=settings.CHATBALLS_AI_EMBEDDING_MODEL,
purpose="retrieval_query",
)[0].vector
semantic = semantic_search(agent, query_vector, limit=limit)
except ProviderError:
semantic = []
lexical = lexical_search(agent, query, limit=limit)
seen = {fragment.id for fragment in semantic}
merged = semantic + [fragment for fragment in lexical if fragment.id not in seen]
return merged[:limit]
query_vector = None
return merge_hits(agent, query, query_vector, limit=limit)
+30 -3
View File
@@ -119,15 +119,19 @@ def knowledge_catalog(agent: AIAgent) -> str:
return "\n\n".join(parts)
def run_agent_turn(
def build_turn_messages(
*,
agent: AIAgent,
message: str,
history: list[dict] | None = None,
fragments: list[KnowledgeFragment],
style_guard: bool = True,
) -> AgentTurnResult:
fragments = KnowledgeRetriever().retrieve(agent=agent, query=message, limit=5)
) -> list[ChatMessage]:
"""Промпт хода целиком: инструкции агента, каталог знаний, найденное, история.
Только чтение базы и склейка строк — обращений наружу здесь нет, поэтому
сборку можно держать внутри транзакции (chatballs.ai.turn).
"""
messages: list[ChatMessage] = []
system_prompt = agent_system_prompt(agent)
if system_prompt:
@@ -156,7 +160,30 @@ def run_agent_turn(
ChatMessage(role=str(item.get("role", "user")), content=str(item.get("content", "")))
)
messages.append(ChatMessage(role="user", content=message))
return messages
def run_agent_turn(
*,
agent: AIAgent,
message: str,
history: list[dict] | None = None,
style_guard: bool = True,
) -> AgentTurnResult:
"""Ход агента целиком, в транзакции вызывающего.
Остаётся для мест, где ждать провайдера под транзакцией не жалко:
предпросмотр на карточке агента и тесты. Ход диалога с клиентом идёт
шагами, вне транзакции (chatballs.ai.turn).
"""
fragments = KnowledgeRetriever().retrieve(agent=agent, query=message, limit=5)
messages = build_turn_messages(
agent=agent,
message=message,
history=history,
fragments=fragments,
style_guard=style_guard,
)
result = invoke_chat(
channel=agent.channel,
messages=messages,
@@ -0,0 +1,67 @@
from django.test import SimpleTestCase
from chatballs.ai.invocation import _breaker, reset_breakers
from chatballs.ai.provider.base import ProviderError
from chatballs.ai.provider.resilience import (
CircuitBreaker,
CircuitBreakerOpen,
call_with_resilience,
)
class ProviderResilienceTests(SimpleTestCase):
def test_retries_count_as_one_logical_failure(self) -> None:
breaker = CircuitBreaker(failure_threshold=2, reset_timeout=999)
calls = {"n": 0}
def always_fail():
calls["n"] += 1
raise ProviderError("down")
for _ in range(2):
with self.assertRaises(ProviderError):
call_with_resilience(
always_fail,
retries=2,
breaker=breaker,
sleep=lambda _s: None,
)
self.assertEqual(calls["n"], 6)
with self.assertRaises(CircuitBreakerOpen):
call_with_resilience(always_fail, retries=2, breaker=breaker)
self.assertEqual(calls["n"], 6)
def test_circuit_breaker_allows_probe_after_cooldown(self) -> None:
now = [0.0]
breaker = CircuitBreaker(
failure_threshold=1,
reset_timeout=30,
clock=lambda: now[0],
)
def fail():
raise ProviderError("down")
with self.assertRaises(ProviderError):
call_with_resilience(fail, retries=0, breaker=breaker)
now[0] = 29
with self.assertRaises(CircuitBreakerOpen):
call_with_resilience(lambda: "ok", retries=0, breaker=breaker)
now[0] = 30
self.assertEqual(
call_with_resilience(lambda: "ok", retries=0, breaker=breaker),
"ok",
)
def test_runtime_revision_replaces_open_breaker(self) -> None:
reset_breakers()
self.addCleanup(reset_breakers)
first = _breaker((1, 2), revision=1)
for _ in range(first.failure_threshold):
first.on_failure()
with self.assertRaises(CircuitBreakerOpen):
first.before()
second = _breaker((1, 2), revision=2)
self.assertIsNot(second, first)
second.before()
+175
View File
@@ -0,0 +1,175 @@
"""Ход агента по шагам: транзакция — сеть — транзакция — сеть — транзакция.
Ответ клиенту складывается из двух обращений к провайдеру (вектор вопроса и
сам ответ) и нескольких обращений к базе между ними. Сделанные подряд, они
держат транзакцию организации открытой всё время ожидания провайдера — а это
минуты (chatballs.ai.invocation). Здесь работа разложена так, чтобы каждое
обращение к базе шло своей короткой транзакцией, а походы наружу оставались
между ними.
Порядок шагов у вызывающего (chatballs.conversations.ai_turn):
1. в транзакции: `plan_query_embedding`
2. вне транзакции: `run_query_embedding`
3. в транзакции: `plan_chat`
4. вне транзакции: `run_turn_chat`
5. в транзакции: `record_turn` и запись ответа
Шаги `run_*` ошибок провайдера не поднимают: отказ — это такой же результат
хода, его пишут в журнал и разбирают в диалоге (передачей оператору).
"""
from __future__ import annotations
import time
from dataclasses import dataclass, field
from django.conf import settings
from chatballs.ai.invocation import (
ChatJob,
EmbeddingJob,
prepare_chat,
prepare_embedding,
record_chat,
record_embedding,
run_chat,
run_embedding,
)
from chatballs.ai.models import AIAgent
from chatballs.ai.provider.base import ChatResult, EmbeddingResult, ProviderError
from chatballs.ai.retrieval import merge_hits
from chatballs.ai.runtime import build_turn_messages
FRAGMENT_LIMIT = 5
def _elapsed_ms(started: float) -> int:
return int((time.monotonic() - started) * 1000)
@dataclass(frozen=True, slots=True)
class QueryEmbedding:
"""Вектор вопроса. Пустой вектор — обычное дело: остаётся лексический поиск."""
vector: list[float] | None = None
model: str = ""
latency_ms: int = 0
results: list[EmbeddingResult] = field(default_factory=list)
@dataclass(frozen=True, slots=True)
class TurnPlan:
"""Готовый запрос к модели и то, на чём он основан."""
job: ChatJob
fragment_ids: list[int]
@dataclass(frozen=True, slots=True)
class TurnAnswer:
"""Итог похода к модели: либо ответ, либо отказ, и сколько это заняло."""
result: ChatResult | None = None
error: ProviderError | None = None
latency_ms: int = 0
def plan_query_embedding(*, agent: AIAgent, query: str) -> EmbeddingJob | None:
"""Шаг в транзакции: чем считать вектор вопроса. None — считать нечем."""
if not query.strip():
return None
try:
return prepare_embedding(
channel=agent.channel,
texts=[query],
model=settings.CHATBALLS_AI_EMBEDDING_MODEL,
timeout=settings.CHATBALLS_AI_TURN_TIMEOUT,
)
except ProviderError:
# Провайдера нет или он не настроен: семантический поиск необязателен.
return None
def run_query_embedding(job: EmbeddingJob | None) -> QueryEmbedding:
"""Шаг без транзакции: обращение к провайдеру за вектором."""
if job is None:
return QueryEmbedding()
started = time.monotonic()
try:
results = run_embedding(job)
except ProviderError:
# Знания найдутся лексическим поиском; ход из-за этого не срывается.
return QueryEmbedding(latency_ms=_elapsed_ms(started))
return QueryEmbedding(
vector=results[0].vector if results else None,
model=job.model,
latency_ms=_elapsed_ms(started),
results=results,
)
def plan_chat(
*,
agent: AIAgent,
message: str,
history: list[dict] | None = None,
embedding: QueryEmbedding | None = None,
style_guard: bool = True,
) -> TurnPlan:
"""Шаг в транзакции: поиск знаний, сборка промпта и выбор модели.
Заодно здесь оседает журнальная строка о векторе вопроса: считали его
снаружи транзакции, а писать её всё равно в базу.
"""
embedding = embedding or QueryEmbedding()
if embedding.results:
record_embedding(
channel=agent.channel,
model=embedding.model,
purpose="retrieval_query",
results=embedding.results,
latency_ms=embedding.latency_ms,
)
fragments = merge_hits(agent, message, embedding.vector, limit=FRAGMENT_LIMIT)
job = prepare_chat(
channel=agent.channel,
messages=build_turn_messages(
agent=agent,
message=message,
history=history,
fragments=fragments,
style_guard=style_guard,
),
model=agent.model,
params=agent.model_params or None,
timeout=settings.CHATBALLS_AI_TURN_TIMEOUT,
)
return TurnPlan(job=job, fragment_ids=[fragment.id for fragment in fragments])
def run_turn_chat(plan: TurnPlan) -> TurnAnswer:
"""Шаг без транзакции: обращение к модели за ответом."""
started = time.monotonic()
try:
result = run_chat(plan.job)
except ProviderError as error:
return TurnAnswer(error=error, latency_ms=_elapsed_ms(started))
return TurnAnswer(result=result, latency_ms=_elapsed_ms(started))
def record_turn(*, agent: AIAgent, plan: TurnPlan, answer: TurnAnswer) -> None:
"""Шаг в транзакции: строка журнала вызовов — и об ответе, и об отказе."""
record_chat(
channel=agent.channel,
job=plan.job,
purpose="agent_chat",
result=answer.result,
error=answer.error,
latency_ms=answer.latency_ms,
used_fragment_ids=plan.fragment_ids,
)
@@ -0,0 +1,302 @@
"""Ход AI по входящему сообщению — отдельная работа, а не часть приёма.
Раньше ответ считался прямо в приёме: цикл опроса мессенджеров и HTTP-запрос
виджета ждали провайдера минутами, держа открытой транзакцию организации, — и
всё это время ни одно другое входящее не забиралось. Теперь приём доводит дело
до записи сообщения и ставит ход в очередь событий, а считает его роль событий
(`run_worker --role=events`), которую можно держать в нескольких процессах.
Границы транзакций здесь и есть главное: каждое обращение к базе идёт своей
короткой транзакцией, походы к провайдеру и в мессенджер остаются между ними.
"""
from __future__ import annotations
import logging
from dataclasses import dataclass
from datetime import timedelta
from django.conf import settings
from django.utils import timezone
from chatballs.ai.models import HISTORY_LIMIT_DEFAULT, AIAgent
from chatballs.ai.provider.base import ProviderError
from chatballs.ai.turn import (
plan_chat,
plan_query_embedding,
record_turn,
run_query_embedding,
run_turn_chat,
)
from chatballs.conversations import ai_turn_result, transports
from chatballs.conversations.models import (
AiTurnState,
ControlMode,
Conversation,
Message,
MessageAuthor,
MessageKind,
)
from chatballs.conversations.transcription import (
TranscriptionJob,
mark_transcription_failed,
prepare_transcription,
run_transcription,
store_transcription,
)
from chatballs.events.services import DomainEvent, enqueue_event
from chatballs.tenancy.context import TenantContext
from chatballs.tenancy.database import tenant_atomic
logger = logging.getLogger(__name__)
AI_TURN_REQUESTED = "conversation.ai_turn_requested"
# Агрегат события — диалог: ходы одного диалога обрабатываются строго по
# очереди (chatballs.events.services.claim_next_outbox_event).
AGGREGATE_TYPE = "Conversation"
_ROLE = {
MessageAuthor.CONTACT: "user",
MessageAuthor.AI: "assistant",
MessageAuthor.OPERATOR: "assistant",
MessageAuthor.SYSTEM: "system",
}
@dataclass(slots=True)
class Turn:
"""Всё о ходе, прочитанное из базы первым шагом."""
message: Message
conversation: Conversation
agent: AIAgent
user_id: str
query: str
history: list[dict]
is_new_conversation: bool = False
transcription_job: TranscriptionJob | None = None
embedding_job: object | None = None
# Ход прерван на подготовке, и клиенту есть что сказать: текст уходит ему
# уже вне транзакции, как и обычный ответ.
stopped: bool = False
outgoing: str = ""
def request_ai_turn(
*,
message: Message,
user_id: str,
context: TenantContext,
is_new_conversation: bool = False,
) -> None:
"""Шаг в транзакции приёма: пометить сообщение и поставить ход в очередь."""
message.ai_turn_state = AiTurnState.PENDING
message.save(update_fields=["ai_turn_state"])
enqueue_event(
DomainEvent(
aggregate_type=AGGREGATE_TYPE,
aggregate_id=str(message.conversation_id),
event_type=AI_TURN_REQUESTED,
payload={
"messageId": message.id,
"userId": user_id,
# Про новый диалог операторов уже позвали при приёме: второй
# оклик из-за нерасшифрованного голосового был бы лишним.
"isNewConversation": is_new_conversation,
},
tenant_context=context,
)
)
def conversation_is_thinking(conversation_id: int) -> bool:
"""Есть ли по диалогу ход, который прямо сейчас считается.
По этому же признаку виджет показывает клиенту, что ответ пишется.
"""
return Message.objects.filter(
conversation_id=conversation_id,
ai_turn_state__in=(AiTurnState.PENDING, AiTurnState.RUNNING),
).exists()
def _history(conversation: Conversation, limit: int) -> list[dict]:
# С конца и с ограничением в базе: длинный диалог не поднимается в память
# целиком ради последних сообщений. Самое новое — входящее, по которому
# идёт ход, оно уходит модели отдельно.
latest = conversation.messages.order_by("-created_at", "-id")[: limit + 1]
prior = list(reversed(latest))[:-1]
# Голосовые попадают в контекст стенограммой.
return [
{"role": _ROLE.get(m.author_type, "user"), "content": m.text or m.transcript}
for m in prior
if m.text or m.transcript
]
def _expired(message: Message) -> bool:
deadline = timedelta(seconds=settings.CHATBALLS_AI_TURN_DEADLINE_SECONDS)
return timezone.now() - message.created_at > deadline
def _plan_transcription(message: Message, channel) -> TranscriptionJob | None:
"""Голосовое без стенограммы: чем её снять. None — снимать нечем."""
if message.kind != MessageKind.VOICE or message.transcript:
return None
try:
return prepare_transcription(channel, message)
except ProviderError as error:
logger.info("Voice transcription unavailable for message %s: %s", message.id, error)
return None
def _begin(*, message_id: int, user_id: str, is_new: bool, context: TenantContext) -> Turn | None:
"""Шаг в транзакции: взять ход в работу — или отказаться от него.
Отказ здесь нормален и молчалив: событие могло приехать вторым заходом
после сбоя, диалог мог уйти оператору, а ход мог пролежать в очереди
дольше, чем ответ имеет смысл.
"""
message = (
Message.objects.select_related(
"conversation__channel__ai_agent",
"conversation__channel__organization",
"conversation__contact",
"conversation__connection",
)
.filter(id=message_id)
.first()
)
if message is None:
return None
if message.ai_turn_state not in (AiTurnState.PENDING, AiTurnState.RUNNING):
return None
conversation = message.conversation
channel = conversation.channel
agent = getattr(channel, "ai_agent", None)
if conversation.control_mode != ControlMode.AI or agent is None or not agent.is_active:
# Диалог успел уйти человеку либо агента отключили: отвечать не нужно.
message.ai_turn_state = AiTurnState.DONE
message.save(update_fields=["ai_turn_state"])
return None
turn = Turn(
message=message,
conversation=conversation,
agent=agent,
user_id=user_id,
query=message.text or message.transcript,
history=_history(conversation, agent.history_limit or HISTORY_LIMIT_DEFAULT),
is_new_conversation=is_new,
)
message.ai_turn_state = AiTurnState.RUNNING
message.save(update_fields=["ai_turn_state"])
if _expired(message):
turn.stopped = True
turn.outgoing = ai_turn_result.store_failure(
turn=turn, context=context, error="turn deadline passed"
)
return turn
turn.transcription_job = _plan_transcription(message, channel)
if turn.transcription_job is not None:
# Вопрос станет известен после расшифровки — вместе с ним и вектор.
return turn
if not turn.query.strip():
# Голосовое, которое нечем расшифровать, и прочее «отвечать не на что».
ai_turn_result.store_voice_without_transcript(turn=turn, context=context)
return None
turn.embedding_job = plan_query_embedding(agent=agent, query=turn.query)
return turn
def _run_transcription(turn: Turn) -> str:
"""Шаг без транзакции: голос в текст."""
try:
return run_transcription(turn.transcription_job)
except ProviderError as error:
logger.info(
"Voice transcription unavailable for message %s: %s", turn.message.id, error
)
return ""
def _apply_transcript(*, turn: Turn, transcript: str, context: TenantContext) -> bool:
"""Шаг в транзакции: сохранить стенограмму. False — хода не будет."""
if not transcript.strip():
mark_transcription_failed(turn.message)
ai_turn_result.store_voice_without_transcript(turn=turn, context=context)
return False
store_transcription(turn.message, transcript)
turn.query = transcript
turn.embedding_job = plan_query_embedding(agent=turn.agent, query=transcript)
return True
def _deliver(turn: Turn, text: str) -> None:
"""Шаг без транзакции: ответ уходит клиенту в его канал.
Веб-виджет забирает ответ поллингом — для него отправка пустая.
"""
if not text or turn.conversation.connection is None:
return
transports.send_reply(
turn.conversation.connection,
chat_id=turn.conversation.external_chat_id,
user_id=turn.user_id,
text=text,
)
def run_requested_turn(payload: dict, context: TenantContext) -> None:
"""Ход целиком: короткие транзакции и походы наружу между ними."""
message_id = int(payload.get("messageId") or 0)
user_id = str(payload.get("userId") or "")
is_new = bool(payload.get("isNewConversation"))
with tenant_atomic(context):
turn = _begin(message_id=message_id, user_id=user_id, is_new=is_new, context=context)
if turn is None:
return
if turn.stopped:
_deliver(turn, turn.outgoing)
return
if turn.transcription_job is not None:
transcript = _run_transcription(turn)
with tenant_atomic(context):
if not _apply_transcript(turn=turn, transcript=transcript, context=context):
return
embedding = run_query_embedding(turn.embedding_job)
failure = None
with tenant_atomic(context):
try:
plan = plan_chat(
agent=turn.agent,
message=turn.query,
history=turn.history,
embedding=embedding,
)
except ProviderError as error:
# Провайдер не настроен вовсе — тот же отказ хода, что и молчание
# модели: клиент получает понятный текст, диалог уходит человеку.
failure = ai_turn_result.store_failure(turn=turn, context=context, error=error)
if failure is not None:
_deliver(turn, failure)
return
answer = run_turn_chat(plan)
with tenant_atomic(context):
record_turn(agent=turn.agent, plan=plan, answer=answer)
if answer.error is not None:
outgoing = ai_turn_result.store_failure(
turn=turn, context=context, error=answer.error
)
else:
outgoing = ai_turn_result.store_answer(
turn=turn, context=context, text=answer.result.text
)
_deliver(turn, outgoing)
@@ -0,0 +1,175 @@
"""Что делать с результатом хода AI: ответ клиенту либо передача оператору.
Отделено от оркестрации (chatballs.conversations.ai_turn) намеренно: там —
порядок шагов и границы транзакций, здесь — правила диалога. Обе функции
вызывают внутри транзакции и обе возвращают текст, который нужно отправить
клиенту: сама отправка — это сеть, и её место снаружи транзакции.
"""
from __future__ import annotations
import logging
from typing import TYPE_CHECKING
from django.utils import timezone
from chatballs.ai.runtime import HANDOFF_TOKEN
from chatballs.conversations.models import (
AiTurnState,
ExpectedResponder,
Message,
MessageAuthor,
SystemEvent,
)
from chatballs.conversations.queue import QUEUE_FIELDS, enter_queue
from chatballs.i18n import customer_language, t
from chatballs.notifications.models import NotificationAudience, NotificationType
from chatballs.notifications.services import notify, notify_management
from chatballs.tenancy.context import TenantContext
if TYPE_CHECKING: # pragma: no cover - только для подсказок типов
from chatballs.conversations.ai_turn import Turn
logger = logging.getLogger(__name__)
def _finish(message: Message, state: str) -> None:
message.ai_turn_state = state
message.save(update_fields=["ai_turn_state"])
def _contact_name(turn: Turn) -> str:
return turn.conversation.contact.name or t("conversations.guest")
def store_answer(*, turn: Turn, context: TenantContext, text: str) -> str:
"""Ответ модели: запись в диалог и, если модель попросила, передача оператору.
Возвращает текст для отправки клиенту.
"""
conversation = turn.conversation
reply = text
handoff = HANDOFF_TOKEN in reply
if handoff:
reply = reply.replace(HANDOFF_TOKEN, "").strip()
Message.objects.create(conversation=conversation, author_type=MessageAuthor.AI, text=reply)
conversation.last_activity_at = timezone.now()
if handoff:
enter_queue(conversation)
else:
conversation.expected_responder = ExpectedResponder.CUSTOMER
conversation.save(update_fields=[*QUEUE_FIELDS, "last_activity_at"])
_finish(turn.message, AiTurnState.DONE)
if handoff:
Message.objects.create(
conversation=conversation,
author_type=MessageAuthor.SYSTEM,
system_event=SystemEvent.AI_HANDED_OVER,
text="AI передал диалог оператору",
)
notify(
context=context,
type=NotificationType.OPERATOR_REQUESTED,
audience=NotificationAudience.OPERATORS,
audience_group=conversation.group,
title=f"AI передал диалог · {_contact_name(turn)}",
title_key="notifications.ai_handed_over",
text_params={"contact": _contact_name(turn)},
body=turn.query[:120],
target_id=conversation.id,
source_type="Conversation",
source_id=conversation.id,
dedup_key=f"handoff:{conversation.id}",
)
return reply
def store_voice_without_transcript(*, turn: Turn, context: TenantContext) -> None:
"""Отвечать не на что: голосовое без стенограммы уходит оператору.
Это не сбой AI, и клиент не должен видеть извинений за поломку: ему просто
ответит человек.
"""
conversation = turn.conversation
enter_queue(conversation)
conversation.save(update_fields=QUEUE_FIELDS)
_finish(turn.message, AiTurnState.FAILED)
if turn.is_new_conversation:
# Про новый диалог операторов уже позвали при приёме.
return
notify(
context=context,
type=NotificationType.OPERATOR_REQUESTED,
audience=NotificationAudience.OPERATORS,
audience_group=conversation.group,
title=f"Нужен оператор · {_contact_name(turn)}",
title_key="notifications.operator_needed",
text_params={"contact": _contact_name(turn)},
body="Голосовое без расшифровки",
body_key="notifications.voice_without_transcript",
target_id=conversation.id,
source_type="Conversation",
source_id=conversation.id,
dedup_key=f"media:{conversation.id}",
)
def store_failure(*, turn: Turn, context: TenantContext, error: object) -> str:
"""Ответа не будет: диалог уходит оператору, клиент получает понятный текст.
Сбой AI не должен «терять» сообщение — ни отказ провайдера, ни ход,
просроченный в очереди.
"""
conversation = turn.conversation
channel = conversation.channel
logger.warning("AI turn failed for conversation %s: %s", conversation.id, error)
enter_queue(conversation)
conversation.last_activity_at = timezone.now()
conversation.save(update_fields=[*QUEUE_FIELDS, "last_activity_at"])
Message.objects.create(
conversation=conversation,
author_type=MessageAuthor.SYSTEM,
system_event=SystemEvent.AI_UNAVAILABLE,
text="AI недоступен — диалог передан оператору",
)
fallback = t(
"conversations.ai_unavailable_reply",
language=customer_language(channel.organization),
)
Message.objects.create(
conversation=conversation, author_type=MessageAuthor.AI, text=fallback
)
_finish(turn.message, AiTurnState.FAILED)
notify(
context=context,
type=NotificationType.OPERATOR_REQUESTED,
audience=NotificationAudience.OPERATORS,
audience_group=conversation.group,
title=f"Нужен оператор · {_contact_name(turn)}",
title_key="notifications.operator_needed",
text_params={"contact": _contact_name(turn)},
body="AI временно недоступен, диалог ждёт ответа",
body_key="notifications.ai_unavailable_waiting",
target_id=conversation.id,
source_type="Conversation",
source_id=conversation.id,
dedup_key=f"aifail:{conversation.id}",
)
notify_management(
context=context,
type=NotificationType.AI_STOPPED,
title=f"Ошибка AI · {channel.name}",
body="AI временно недоступен, диалог передан оператору",
title_key="notifications.ai_error",
body_key="notifications.ai_unavailable_handed_over",
text_params={"channel": channel.name},
target_id=conversation.id,
source_type="Conversation",
source_id=conversation.id,
dedup_key=f"aierror:{conversation.id}",
)
return fallback
+4 -1
View File
@@ -9,4 +9,7 @@ class ConversationsConfig(AppConfig):
def ready(self) -> None:
# Свежесть диалога поддерживает сигнал: сообщения создаются в семи местах.
from chatballs.conversations import signals # noqa: F401
from chatballs.conversations import (
event_handlers, # noqa: F401 (register outbox handlers)
signals, # noqa: F401
)
@@ -7,6 +7,8 @@
from __future__ import annotations
import re
from django.db.models import Count, Q
from django.utils import timezone
from rest_framework.request import Request
@@ -38,6 +40,26 @@ def _label_payload(label: ConversationLabel) -> dict[str, object]:
return {"id": label.id, "name": label.name, "color": label.color}
# Переменные шаблонов ответов: подставляет их интерфейс оператора при вставке
# шаблона (internal-ui, conversations/templateVariables.ts — тот же список).
TEMPLATE_VARIABLES = frozenset({"client_name", "operator_name", "company"})
_TEMPLATE_TOKEN = re.compile(r"\{\{\s*(\w+)\s*\}\}")
def _template_text_error(text: str) -> Response | None:
if not text:
return Response({"detail": t("conversations.template_text_required")}, status=400)
unknown = sorted(
{match.group(0) for match in _TEMPLATE_TOKEN.finditer(text) if match.group(1) not in TEMPLATE_VARIABLES}
)
if unknown:
return Response(
{"detail": t("conversations.template_unknown_variables", names=", ".join(unknown))},
status=400,
)
return None
def _template_payload(template: ReplyTemplate) -> dict[str, object]:
return {
"id": template.id,
@@ -443,8 +465,8 @@ class ReplyTemplateListView(APIView):
text = str(request.data.get("text", "")).strip()
if not title or len(title) > 120:
return Response({"detail": t("conversations.template_name_length")}, status=400)
if not text:
return Response({"detail": t("conversations.template_text_required")}, status=400)
if error := _template_text_error(text):
return error
if ReplyTemplate.objects.filter(
organization_id=request.tenant_context.organization_id, title__iexact=title
).exists():
@@ -475,11 +497,15 @@ class ReplyTemplateDetailView(APIView):
title = str(request.data.get("title", "")).strip()
if not title or len(title) > 120:
return Response({"detail": t("conversations.template_name_length")}, status=400)
if ReplyTemplate.objects.filter(
organization_id=template.organization_id, title__iexact=title
).exclude(id=template.id).exists():
return Response({"detail": t("conversations.template_name_taken")}, status=409)
template.title = title
if "text" in request.data:
text = str(request.data.get("text", "")).strip()
if not text:
return Response({"detail": t("conversations.template_text_required")}, status=400)
if error := _template_text_error(text):
return error
template.text = text
template.save()
return Response({"template": _template_payload(template)})
@@ -0,0 +1,14 @@
"""Обработчики outbox-событий домена диалогов."""
from chatballs.conversations.ai_turn import AI_TURN_REQUESTED, run_requested_turn
from chatballs.events.handlers import register
from chatballs.tenancy.context import TenantContext
@register(AI_TURN_REQUESTED, manages_own_transaction=True)
def handle_ai_turn_requested(payload: dict, context: TenantContext | None) -> None:
"""Ход AI сам управляет транзакциями: он ходит к провайдеру и в мессенджер,
и держать ради этого одну транзакцию на весь обработчик нельзя."""
if context is None: # pragma: no cover - событие диалога всегда арендное
return
run_requested_turn(payload, context)
+23 -219
View File
@@ -1,23 +1,23 @@
"""Inbound ingest for messenger connections (M2a).
"""Приём входящих из подключений (M2a).
One inbound message -> contact/conversation/message -> AI turn (if the dialog is
AI-controlled) -> outbound reply. Idempotent via the events InboxEvent.
Одно входящее -> контакт/диалог/сообщение -> заявка на ход AI, если диалог
ведёт агент. Повторы отсекаются через InboxEvent.
Обращений наружу здесь нет и быть не должно: приём вызывают цикл опроса
мессенджеров и HTTP-запрос виджета, и ждать провайдера ни тот, ни другой не
может. Ответ считает роль событий (chatballs.conversations.ai_turn).
"""
from __future__ import annotations
import hashlib
import logging
from dataclasses import dataclass
from django.db import IntegrityError, transaction
from django.utils import timezone
from chatballs.ai.models import HISTORY_LIMIT_DEFAULT
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.ai_turn import request_ai_turn
from chatballs.conversations.contact_avatars import refresh_contact_avatar
from chatballs.conversations.models import (
ConnectionIdentity,
@@ -29,26 +29,17 @@ from chatballs.conversations.models import (
Message,
MessageAuthor,
MessageKind,
SystemEvent,
TranscriptStatus,
)
from chatballs.conversations.queue import QUEUE_FIELDS, enter_queue, is_waiting
from chatballs.conversations.transports.base import InboundMessage
from chatballs.events.models import EventOwnership, InboxEvent
from chatballs.i18n import t
from chatballs.notifications.models import NotificationAudience, NotificationType
from chatballs.notifications.services import notify, notify_management
from chatballs.notifications.services import notify
from chatballs.tenancy.context import TenantContext
logger = logging.getLogger(__name__)
_ROLE = {
MessageAuthor.CONTACT: "user",
MessageAuthor.AI: "assistant",
MessageAuthor.OPERATOR: "assistant",
MessageAuthor.SYSTEM: "system",
}
def _already_processed(context: TenantContext, source: str, external_id: str, text: str) -> bool:
"""Отметить сообщение обработанным; True — оно уже приходило.
@@ -74,105 +65,6 @@ def _already_processed(context: TenantContext, source: str, external_id: str, te
return True
def _history(conversation: Conversation, limit: int) -> list[dict]:
# С конца и с ограничением в базе: длинный диалог не поднимается в память
# целиком ради последних сообщений. Самое новое — только что сохранённое
# входящее, оно уходит модели отдельно.
latest = conversation.messages.order_by("-created_at", "-id")[: limit + 1]
prior = list(reversed(latest))[:-1]
# Голосовые попадают в контекст стенограммой.
return [{"role": _ROLE.get(m.author_type, "user"), "content": m.text or m.transcript} for m in prior if m.text or m.transcript]
@dataclass(frozen=True, slots=True)
class TranscriptionJob:
"""Всё, что нужно провайдеру, — уже прочитанное из базы и хранилища.
Разложено на три шага (``prepare`` → ``run`` → ``store``), чтобы вызывающий
мог держать транзакцию только вокруг первого и третьего: обращение к
провайдеру ждёт ответа десятки секунд, и всё это время транзакция занимала
бы соединение из пула (chatballs.tenancy.middleware).
"""
provider: object
model: str
audio: bytes
filename: str
content_type: str
def prepare_transcription(channel, message: Message) -> TranscriptionJob | None:
"""Шаг в транзакции: провайдер организации, модель и байты аудио."""
from chatballs.ai.provider.factory import get_transcription_provider
from chatballs.ai.provider.routing import (
DEFAULT_TRANSCRIPTION_MODEL,
resolve_transcription_model,
)
if not message.audio:
return None
provider = get_transcription_provider(channel=channel)
try:
model = resolve_transcription_model(channel)
except ProviderError:
model = DEFAULT_TRANSCRIPTION_MODEL # тестовый провайдер без интеграции
with message.audio.open("rb") as handle:
audio = handle.read()
return TranscriptionJob(
provider=provider,
model=model,
audio=audio,
filename=message.audio.name.rsplit("/", 1)[-1],
content_type=message.audio_content_type or "audio/ogg",
)
def run_transcription(job: TranscriptionJob) -> str:
"""Шаг без транзакции: обращение к провайдеру."""
return job.provider.transcribe(
audio=job.audio,
filename=job.filename,
content_type=job.content_type,
model=job.model,
).strip()
def store_transcription(message: Message, transcript: str) -> None:
"""Шаг в транзакции: сохранить стенограмму и статус."""
message.transcript = transcript
message.transcript_status = TranscriptStatus.READY if transcript else TranscriptStatus.FAILED
message.save(update_fields=["transcript", "transcript_status"])
def mark_transcription_failed(message: Message) -> None:
"""Статус FAILED — оператор повторит кнопкой."""
message.transcript_status = TranscriptStatus.FAILED
message.save(update_fields=["transcript_status"])
def transcribe_voice_message(channel, message: Message, *, raise_errors: bool = False) -> str:
"""Стенограмма голосового через BYOK-провайдера организации; пустая строка,
если провайдер не умеет или недоступен (статус FAILED — оператор повторит кнопкой).
Три шага подряд, в транзакции вызывающего: так входящее сообщение
обрабатывается целиком (ingest_inbound). Оператору, нажавшему «расшифровать»,
ждать под транзакцией незачем — там шаги разнесены (voice_views).
"""
try:
job = prepare_transcription(channel, message)
if job is None:
return ""
transcript = run_transcription(job)
except ProviderError as error:
logger.info("Voice transcription unavailable for message %s: %s", message.id, error)
mark_transcription_failed(message)
if raise_errors:
raise
return ""
store_transcription(message, transcript)
return transcript
def ingest_inbound(integration, inbound: InboundMessage) -> None:
channel = integration.channel
if channel is None:
@@ -374,13 +266,10 @@ def ingest_inbound(integration, inbound: InboundMessage) -> None:
transports.send_contact_ack(integration, chat_id=conversation.external_chat_id, user_id=inbound.user_id, text=ack)
return
# Голосовое: AI отвечает текстом по стенограмме (BYOK-провайдер). Если
# расшифровка недоступна, а также для файлов без текста — диалог уходит
# оператору, как при недоступном AI, но без имитации сбоя.
ai_input = inbound.text
if is_voice and conversation.control_mode == ControlMode.AI and ai_available:
ai_input = transcribe_voice_message(channel, message)
if (is_voice and not ai_input) or files_only:
# Файлы без текста: отвечать не на что — диалог уходит оператору, как при
# недоступном AI, но без имитации сбоя. Голосовое сюда не попадает: его
# расшифровка — это обращение к провайдеру, и она идёт ходом AI.
if files_only:
if conversation.control_mode == ControlMode.AI:
enter_queue(conversation)
conversation.save(update_fields=QUEUE_FIELDS)
@@ -394,8 +283,7 @@ def ingest_inbound(integration, inbound: InboundMessage) -> None:
title=f"Нужен оператор · {contact.name or 'Гость'}",
title_key="notifications.operator_needed",
text_params={"contact": contact.name or t("conversations.guest")},
body="Голосовое без расшифровки" if is_voice else message_text[:120],
body_key="notifications.voice_without_transcript" if is_voice else "",
body=message_text[:120],
target_id=conversation.id,
source_type="Conversation",
source_id=conversation.id,
@@ -408,99 +296,15 @@ def ingest_inbound(integration, inbound: InboundMessage) -> None:
if conversation.control_mode != ControlMode.AI:
return
try:
result = run_channel_turn(
channel=channel,
message=ai_input,
history=_history(
conversation, agent.history_limit if agent else HISTORY_LIMIT_DEFAULT
),
)
except ProviderError as error:
# Сбой AI не должен «терять» сообщение: переводим диалог в очередь к
# оператору, уведомляем и отвечаем клиенту понятным fallback.
logger.warning("AI turn failed for conversation %s: %s", conversation.id, error)
enter_queue(conversation)
conversation.last_activity_at = timezone.now()
conversation.save(update_fields=[*QUEUE_FIELDS, "last_activity_at"])
Message.objects.create(
conversation=conversation,
author_type=MessageAuthor.SYSTEM,
system_event=SystemEvent.AI_UNAVAILABLE,
text="AI недоступен — диалог передан оператору",
)
fallback = "Извините, прямо сейчас не получается ответить. Я передал ваш вопрос специалисту — он скоро подключится."
Message.objects.create(conversation=conversation, author_type=MessageAuthor.AI, text=fallback)
notify(
context=context,
type=NotificationType.OPERATOR_REQUESTED,
audience=NotificationAudience.OPERATORS,
audience_group=conversation.group,
title=f"Нужен оператор · {contact.name or 'Гость'}",
title_key="notifications.operator_needed",
text_params={"contact": contact.name or t("conversations.guest")},
body="AI временно недоступен, диалог ждёт ответа",
body_key="notifications.ai_unavailable_waiting",
target_id=conversation.id,
source_type="Conversation",
source_id=conversation.id,
dedup_key=f"aifail:{conversation.id}",
)
notify_management(
context=context,
type=NotificationType.AI_STOPPED,
title=f"Ошибка AI · {channel.name}",
body="AI временно недоступен, диалог передан оператору",
title_key="notifications.ai_error",
body_key="notifications.ai_unavailable_handed_over",
text_params={"channel": channel.name},
target_id=conversation.id,
source_type="Conversation",
source_id=conversation.id,
dedup_key=f"aierror:{conversation.id}",
)
transports.send_reply(integration, chat_id=conversation.external_chat_id, user_id=inbound.user_id, text=fallback)
return
reply = result.text
handoff = HANDOFF_TOKEN in reply
if handoff:
reply = reply.replace(HANDOFF_TOKEN, "").strip()
Message.objects.create(conversation=conversation, author_type=MessageAuthor.AI, text=reply)
conversation.last_activity_at = timezone.now()
if handoff:
enter_queue(conversation)
else:
conversation.expected_responder = ExpectedResponder.CUSTOMER
conversation.save(update_fields=[*QUEUE_FIELDS, "last_activity_at"])
if handoff:
Message.objects.create(
conversation=conversation,
author_type=MessageAuthor.SYSTEM,
system_event=SystemEvent.AI_HANDED_OVER,
text="AI передал диалог оператору",
)
notify(
context=context,
type=NotificationType.OPERATOR_REQUESTED,
audience=NotificationAudience.OPERATORS,
audience_group=conversation.group,
title=f"AI передал диалог · {contact.name or 'Гость'}",
title_key="notifications.ai_handed_over",
text_params={"contact": contact.name or t("conversations.guest")},
body=ai_input[:120],
target_id=conversation.id,
source_type="Conversation",
source_id=conversation.id,
dedup_key=f"handoff:{conversation.id}",
)
if reply:
transports.send_reply(
integration, chat_id=conversation.external_chat_id, user_id=inbound.user_id, text=reply
)
# Ход AI — отдельная работа: обращение к модели ждёт ответа секунды и
# десятки секунд, а приём входящих столько ждать не может. Здесь только
# заявка; считает ход роль событий (chatballs.conversations.ai_turn).
request_ai_turn(
message=message,
user_id=inbound.user_id,
context=context,
is_new_conversation=is_new,
)
def _store_attachment(integration, inbound_file, message: Message) -> None:
@@ -0,0 +1,18 @@
# Generated by Django 5.2.16 on 2026-09-20 02:00
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('conversations', '0025_contact_avatar_contact_avatar_content_type_and_more'),
]
operations = [
migrations.AddField(
model_name='message',
name='ai_turn_state',
field=models.CharField(choices=[('NONE', 'Ход не нужен'), ('PENDING', 'Ожидает'), ('RUNNING', 'Считается'), ('DONE', 'Отвечено'), ('FAILED', 'Не удалось')], default='NONE', max_length=8),
),
]
@@ -324,6 +324,22 @@ class MessageKind(models.TextChoices):
FILE = "file", "Файл"
class AiTurnState(models.TextChoices):
"""Состояние хода AI по входящему сообщению.
Ответ считается не в приёме, а отдельной ролью воркера
(chatballs.conversations.ai_turn), поэтому у входящего появилось состояние.
По нему видно, что ответ ещё считается — виджет показывает «печатает», — и
по нему же повторная доставка события не приводит ко второму ответу.
"""
NONE = "NONE", "Ход не нужен"
PENDING = "PENDING", "Ожидает"
RUNNING = "RUNNING", "Считается"
DONE = "DONE", "Отвечено"
FAILED = "FAILED", "Не удалось"
class TranscriptStatus(models.TextChoices):
# Расшифровка голосового (дизайн-базлайн v2, кадр H): по кнопке, через
# BYOK-провайдера организации (решение владельца 2026-09-04).
@@ -375,6 +391,10 @@ class Message(TenantRelationModel):
transcript_status = models.CharField(
max_length=8, choices=TranscriptStatus.choices, default=TranscriptStatus.NONE
)
# Ход AI по этому сообщению: ожидает, считается, отвечено, не удалось.
ai_turn_state = models.CharField(
max_length=8, choices=AiTurnState.choices, default=AiTurnState.NONE
)
# Файл/фото (kind=FILE): вложение с исходным именем, типом и размером.
attachment = models.FileField(upload_to=message_attachment_upload_path, max_length=512, blank=True)
attachment_name = models.CharField(max_length=255, blank=True)
@@ -157,15 +157,28 @@ def _history_item(conversation: Conversation) -> dict[str, object]:
}
def _contact_username(conversation: Conversation) -> str:
# Username живёт на identity подключения (у контакта их может быть несколько).
# Только в detail-режиме — в списках это лишний запрос на каждый диалог.
def _connection_identity(conversation: Conversation) -> ConnectionIdentity | None:
# Username и подпись гостя живут на identity подключения (у контакта их
# может быть несколько). Только в detail-режиме — в списках это лишний
# запрос на каждый диалог.
if not conversation.connection_id:
return ""
identity = ConnectionIdentity.objects.filter(
return None
return ConnectionIdentity.objects.filter(
connection_id=conversation.connection_id, contact_id=conversation.contact_id
).first()
return identity.username if identity else ""
def _contact_is_guest(contact, identity: ConnectionIdentity | None) -> bool:
# Гость виджета получает имя «Гость · <код сессии>» на языке организации;
# та же подпись записана в display_name его identity. Пока имя не сменили,
# настоящего имени у контакта нет — подставлять его в ответ нельзя.
if not contact.name:
return True
return bool(
identity
and identity.display_name == contact.name
and identity.external_user_id[:6] in contact.name
)
def _contact_email(conversation: Conversation) -> str:
@@ -206,6 +219,7 @@ def conversation_payload(
# pending_counts_for); поштучный расчёт остаётся для одиночных ответов.
last = None if detailed else (last_message or _last_message(conversation))
channel = conversation.channel
identity = _connection_identity(conversation) if detailed and conversation.contact_id else None
payload = {
"id": conversation.id,
"channel": {
@@ -236,7 +250,9 @@ def conversation_payload(
"company": conversation.contact.company,
"city": conversation.contact.city,
"email": _contact_email(conversation),
"username": _contact_username(conversation) if detailed else "",
"username": identity.username if identity else "",
# Для переменной {{client_name}} шаблонов ответов.
**({"isGuest": _contact_is_guest(conversation.contact, identity)} if detailed else {}),
}
if conversation.contact_id
else None
@@ -0,0 +1,141 @@
"""Ход AI как отдельная работа: приём не ждёт модель, ответ считается событием."""
from unittest import mock
from django.test import TestCase, override_settings
from chatballs.ai.models import AIAgent, AIAgentStatus
from chatballs.channels.models import Channel
from chatballs.conversations.ai_turn import AI_TURN_REQUESTED
from chatballs.conversations.ingest import ingest_inbound
from chatballs.conversations.models import (
AiTurnState,
ControlMode,
ExpectedResponder,
Message,
MessageAuthor,
)
from chatballs.conversations.transports.base import InboundMessage
from chatballs.events.handlers import dispatch
from chatballs.events.models import OutboxEvent
from chatballs.identity.bootstrap import bootstrap_owner
from chatballs.identity.models import Organization
from chatballs.integrations.models import Integration, IntegrationKind, IntegrationProvider
from chatballs.testing import ai_answer, run_pending_ai_turns
class AiTurnQueueTests(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="Линия"
)
AIAgent.objects.create(
channel=self.channel,
name="Агент",
model="openai/gpt-4o-mini",
status=AIAgentStatus.ACTIVE,
)
self.integration = Integration.objects.create(
organization=self.organization,
kind=IntegrationKind.MESSENGER,
provider=IntegrationProvider.TELEGRAM,
name="Bot",
secret="token",
channel=self.channel,
)
self.inbound = InboundMessage(
external_id="ext-1",
user_id="u-1",
chat_id="c-1",
text="Здравствуйте",
display_name="Гость",
)
def _inbound_message(self) -> Message:
return Message.objects.get(author_type=MessageAuthor.CONTACT)
def _ai_messages(self):
return Message.objects.filter(author_type=MessageAuthor.AI)
def test_ingest_queues_the_turn_and_does_not_call_the_model(self) -> None:
# Главное свойство всей развязки: приём не ждёт провайдера.
with mock.patch("chatballs.ai.provider.local.LocalProvider.chat") as chat:
ingest_inbound(self.integration, self.inbound)
chat.assert_not_called()
message = self._inbound_message()
self.assertEqual(message.ai_turn_state, AiTurnState.PENDING)
self.assertFalse(self._ai_messages().exists())
self.assertTrue(
OutboxEvent.objects.filter(
event_type=AI_TURN_REQUESTED, aggregate_id=str(message.conversation_id)
).exists()
)
def test_turn_answers_and_closes_the_message(self) -> None:
with (
ai_answer("Здравствуйте!"),
mock.patch(
"chatballs.conversations.transports.send_reply", return_value=True
) as send,
):
ingest_inbound(self.integration, self.inbound)
self.assertEqual(run_pending_ai_turns(), 1)
self.assertEqual(self._ai_messages().get().text, "Здравствуйте!")
self.assertEqual(self._inbound_message().ai_turn_state, AiTurnState.DONE)
conversation = self.channel.conversations.get()
self.assertEqual(conversation.control_mode, ControlMode.AI)
self.assertEqual(conversation.expected_responder, ExpectedResponder.CUSTOMER)
send.assert_called_once()
def test_repeated_delivery_does_not_answer_twice(self) -> None:
# Событие могут привезти второй раз: процесс упал между ответом и
# отметкой о нём. Второй ответ клиенту — это хуже, чем ни одного.
with (
ai_answer("Здравствуйте!"),
mock.patch("chatballs.conversations.transports.send_reply", return_value=True),
):
ingest_inbound(self.integration, self.inbound)
event = OutboxEvent.objects.get(event_type=AI_TURN_REQUESTED)
dispatch(event)
dispatch(event)
self.assertEqual(self._ai_messages().count(), 1)
def test_turn_for_a_dialog_taken_by_an_operator_is_dropped(self) -> None:
with (
ai_answer("Здравствуйте!"),
mock.patch("chatballs.conversations.transports.send_reply", return_value=True),
):
ingest_inbound(self.integration, self.inbound)
conversation = self.channel.conversations.get()
conversation.control_mode = ControlMode.HUMAN
conversation.save(update_fields=["control_mode"])
run_pending_ai_turns()
self.assertFalse(self._ai_messages().exists())
self.assertEqual(self._inbound_message().ai_turn_state, AiTurnState.DONE)
@override_settings(CHATBALLS_AI_TURN_DEADLINE_SECONDS=0)
def test_expired_turn_goes_to_the_operator_instead_of_the_model(self) -> None:
# Ответ, пролежавший в очереди, клиенту уже не нужен — нужен человек.
with (
mock.patch("chatballs.ai.provider.local.LocalProvider.chat") as chat,
mock.patch(
"chatballs.conversations.transports.send_reply", return_value=True
) as send,
):
ingest_inbound(self.integration, self.inbound)
run_pending_ai_turns()
chat.assert_not_called()
conversation = self.channel.conversations.get()
self.assertEqual(conversation.control_mode, ControlMode.PAUSED)
self.assertEqual(conversation.expected_responder, ExpectedResponder.OPERATOR)
self.assertEqual(self._inbound_message().ai_turn_state, AiTurnState.FAILED)
self.assertTrue(self._ai_messages().filter(text__contains="специалисту").exists())
# Клиент получает этот текст в своём канале, а не только в базе.
send.assert_called_once()
@@ -1,10 +1,11 @@
import json
from django.test import TestCase
from django.test import SimpleTestCase, TestCase
from django.utils import timezone
from chatballs.channels.models import Channel
from chatballs.conversations.models import (
ConnectionIdentity,
Contact,
ControlMode,
Conversation,
@@ -13,6 +14,7 @@ from chatballs.conversations.models import (
LifecycleState,
ReplyTemplate,
)
from chatballs.conversations.serializers import _contact_is_guest
from chatballs.identity.group_models import EmployeeGroup, EmployeeGroupMember
from chatballs.identity.models import (
EmployeeRole,
@@ -339,6 +341,65 @@ class ReplyTemplateTests(ChatExtrasTestCase):
self.assertEqual(deleted.status_code, 204)
self.assertFalse(ReplyTemplate.objects.exists())
def test_rename_to_taken_title_conflicts(self) -> None:
ReplyTemplate.objects.create(
organization_id=self.organization.id, title="Приветствие", text="Здравствуйте!"
)
other = ReplyTemplate.objects.create(
organization_id=self.organization.id, title="Прощание", text="До свидания!"
)
taken = self.admin_client.patch(
f"/api/v1/conversations/templates/{other.id}/",
data=json.dumps({"title": "приветствие"}),
content_type="application/json",
)
self.assertEqual(taken.status_code, 409)
other.refresh_from_db()
self.assertEqual(other.title, "Прощание")
# Смена регистра собственного названия — не конфликт.
recased = self.admin_client.patch(
f"/api/v1/conversations/templates/{other.id}/",
data=json.dumps({"title": "ПРОЩАНИЕ"}),
content_type="application/json",
)
self.assertEqual(recased.status_code, 200)
self.assertEqual(recased.json()["template"]["title"], "ПРОЩАНИЕ")
def test_template_variables_are_checked(self) -> None:
created = self.admin_client.post(
"/api/v1/conversations/templates/",
data=json.dumps({"title": "Приветствие", "text": "Здравствуйте, {{client_name}}! Я {{ operator_name }}, {дата}."}),
content_type="application/json",
)
self.assertEqual(created.status_code, 201)
typo = self.admin_client.post(
"/api/v1/conversations/templates/",
data=json.dumps({"title": "Опечатка", "text": "Здравствуйте, {{clent_name}}!"}),
content_type="application/json",
)
self.assertEqual(typo.status_code, 400)
self.assertIn("{{clent_name}}", typo.json()["detail"])
patched = self.admin_client.patch(
f"/api/v1/conversations/templates/{created.json()['template']['id']}/",
data=json.dumps({"text": "{{order_id}}"}),
content_type="application/json",
)
self.assertEqual(patched.status_code, 400)
class ContactGuestTests(SimpleTestCase):
def test_widget_guest_label_is_not_a_name(self) -> None:
identity = ConnectionIdentity(external_user_id="3ffa7b0c9d", display_name="Гость · 3ffa7b")
self.assertTrue(_contact_is_guest(Contact(name="Гость · 3ffa7b"), identity))
self.assertTrue(_contact_is_guest(Contact(name=""), None))
# Гость представился — имя уже настоящее.
self.assertFalse(_contact_is_guest(Contact(name="Дмитрий Орлов"), identity))
self.assertFalse(_contact_is_guest(Contact(name="Иван"), None))
class OnboardingTests(TestCase):
def setUp(self) -> None:
@@ -19,6 +19,7 @@ from chatballs.conversations.transports.errors import PollFailed
from chatballs.identity.bootstrap import bootstrap_owner
from chatballs.identity.models import Organization
from chatballs.integrations.models import Integration, IntegrationKind, IntegrationProvider
from chatballs.testing import ai_answer, run_pending_ai_turns
EMAIL_CONFIG = {
@@ -505,14 +506,16 @@ class EmailIngestThreadMetaTests(TestCase):
with (
mock.patch("chatballs.conversations.ingest.run_channel_turn", return_value=mock.Mock(text="Ответ")),
ai_answer("Ответ"),
mock.patch("chatballs.conversations.ingest.transports.send_reply", return_value=True),
mock.patch("chatballs.conversations.transports.send_reply", return_value=True),
):
ingest_inbound(self.integration, inbound)
run_pending_ai_turns()
def test_subject_pinned_to_first_message_id_follows_last(self) -> None:
@@ -23,6 +23,7 @@ from chatballs.integrations.models import (
IntegrationProvider,
)
from chatballs.testing import TenantAPIClient as APIClient
from chatballs.testing import run_pending_ai_turns
def _connection(channel: Channel) -> Integration:
@@ -71,33 +72,21 @@ class OperatorOnlyIngestTests(TestCase):
def _ingest_without_ai(self, inbound: InboundMessage) -> tuple[mock.Mock, mock.Mock]:
def _ingest_without_ai(self, inbound: InboundMessage) -> tuple[int, mock.Mock]:
with (
mock.patch(
"chatballs.conversations.ingest.run_channel_turn"
) as ai_turn,
mock.patch(
"chatballs.conversations.ingest.transports.send_reply"
) as send,
):
with mock.patch("chatballs.conversations.transports.send_reply") as send:
ingest_inbound(self.integration, inbound)
return ai_turn, send
turns = run_pending_ai_turns()
return turns, send
def test_new_dialog_starts_in_queue_without_ai_fallback(self) -> None:
ai_turn, send = self._ingest_without_ai(
turns, send = self._ingest_without_ai(
InboundMessage(
@@ -135,7 +124,7 @@ class OperatorOnlyIngestTests(TestCase):
)
ai_turn.assert_not_called()
self.assertEqual(turns, 0)
send.assert_not_called()
@@ -179,7 +168,7 @@ class OperatorOnlyIngestTests(TestCase):
ai_turn, send = self._ingest_without_ai(
turns, send = self._ingest_without_ai(
InboundMessage(
@@ -211,7 +200,7 @@ class OperatorOnlyIngestTests(TestCase):
self.assertEqual(conversation.messages.count(), 1)
ai_turn.assert_not_called()
self.assertEqual(turns, 0)
send.assert_not_called()
@@ -26,7 +26,7 @@ from chatballs.identity.bootstrap import bootstrap_owner
from chatballs.identity.models import HumanUser, Organization
from chatballs.integrations.models import Integration, IntegrationKind, IntegrationProvider
from chatballs.notifications.models import Notification, NotificationType
from chatballs.testing import tenant_context_for
from chatballs.testing import ai_answer, tenant_context_for
class QueueTestBase(TestCase):
@@ -55,10 +55,7 @@ class QueueTestBase(TestCase):
text=text,
display_name=chat_id,
)
with (
mock.patch("chatballs.conversations.ingest.run_channel_turn"),
mock.patch("chatballs.conversations.ingest.transports.send_reply"),
):
with mock.patch("chatballs.conversations.transports.send_reply"):
ingest_inbound(self.integration, inbound)
def _waiting_order(self) -> list[int]:
@@ -155,10 +152,9 @@ class NewDialogNotificationTests(QueueTestBase):
is_active=True,
)
self.assertTrue(agent.is_active)
with mock.patch(
"chatballs.conversations.ingest.run_channel_turn",
return_value=mock.Mock(text="Здравствуйте!"),
), mock.patch("chatballs.conversations.ingest.transports.send_reply"):
with ai_answer("Здравствуйте!"), mock.patch(
"chatballs.conversations.transports.send_reply"
):
ingest_inbound(
self.integration,
InboundMessage(
@@ -9,6 +9,7 @@ from django.test import TestCase
from chatballs.ai.models import AIAgent, AIAgentStatus
from chatballs.ai.provider.base import ProviderError
from chatballs.channels.models import Channel
from chatballs.conversations import ai_turn
from chatballs.conversations.ingest import ingest_inbound
from chatballs.conversations.models import ControlMode, MessageAuthor, MessageKind, TranscriptStatus
from chatballs.conversations.transports.base import InboundMessage
@@ -17,6 +18,7 @@ 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
from chatballs.testing import ai_answer, run_pending_ai_turns
class VoiceAiReplyTests(TestCase):
@@ -30,43 +32,53 @@ class VoiceAiReplyTests(TestCase):
)
self.inbound = InboundMessage(external_id="v-1", user_id="u-1", chat_id="c-1", text="", display_name="Ольга", voice_file_id="f-1", voice_duration=5, voice_mime="audio/ogg")
def _ingest(self, transcribe, turn):
def _ingest(self, transcribe, answer="Ответ"):
"""Приём голосового и ход AI по нему.
Расшифровка — обращение к провайдеру, поэтому она идёт не в приёме, а
в ходе (chatballs.conversations.ai_turn); тест повторяет этот порядок.
"""
with (
mock.patch("chatballs.conversations.ingest.transports.download_voice", return_value=(b"OGG", "audio/ogg")),
mock.patch("chatballs.conversations.ingest.transports.send_reply", return_value=True) as send,
mock.patch("chatballs.conversations.transports.send_reply", return_value=True) as send,
mock.patch("chatballs.ai.provider.local.LocalProvider.transcribe", **transcribe),
mock.patch("chatballs.conversations.ingest.run_channel_turn", **turn) as run,
mock.patch("chatballs.conversations.ai_turn.plan_chat", wraps=ai_turn.plan_chat) as plan,
ai_answer(answer),
tenant_atomic(self.organization.id),
):
ingest_inbound(self.integration, self.inbound)
return send, run
run_pending_ai_turns()
return send, plan
def test_ai_answers_voice_by_transcript(self) -> None:
send, run = self._ingest({"return_value": "Можно оформить возврат?"}, {"return_value": mock.Mock(text="Да, возврат возможен в течение 14 дней.")})
send, plan = self._ingest(
{"return_value": "Можно оформить возврат?"},
answer="Да, возврат возможен в течение 14 дней.",
)
conversation = self.channel.conversations.get()
voice = conversation.messages.get(kind=MessageKind.VOICE)
self.assertEqual(voice.transcript, "Можно оформить возврат?")
self.assertEqual(voice.transcript_status, TranscriptStatus.READY)
run.assert_called_once()
self.assertEqual(run.call_args.kwargs["message"], "Можно оформить возврат?")
plan.assert_called_once()
self.assertEqual(plan.call_args.kwargs["message"], "Можно оформить возврат?")
reply = conversation.messages.get(author_type=MessageAuthor.AI)
self.assertIn("возврат", reply.text)
send.assert_called_once()
self.assertEqual(conversation.control_mode, ControlMode.AI)
def test_without_transcription_dialog_goes_to_operator(self) -> None:
send, run = self._ingest({"side_effect": ProviderError("нет STT")}, {"return_value": mock.Mock(text="x")})
send, plan = self._ingest({"side_effect": ProviderError("нет STT")})
conversation = self.channel.conversations.get()
voice = conversation.messages.get(kind=MessageKind.VOICE)
self.assertEqual(voice.transcript_status, TranscriptStatus.FAILED)
run.assert_not_called()
plan.assert_not_called()
send.assert_not_called()
self.assertEqual(conversation.control_mode, ControlMode.PAUSED)
def test_transcript_is_in_ai_history(self) -> None:
from chatballs.conversations.ingest import _history
from chatballs.conversations.ai_turn import _history
self._ingest({"return_value": "Первый вопрос"}, {"return_value": mock.Mock(text="Ответ")})
self._ingest({"return_value": "Первый вопрос"})
conversation = self.channel.conversations.get()
conversation.messages.create(author_type=MessageAuthor.CONTACT, text="Второй")
roles = [(h["role"], h["content"]) for h in _history(conversation, 20)]
@@ -75,16 +87,16 @@ class VoiceAiReplyTests(TestCase):
def test_ai_history_window_follows_agent_setting(self) -> None:
self.channel.ai_agent.history_limit = 3
self.channel.ai_agent.save(update_fields=["history_limit"])
self._ingest({"return_value": "Первый вопрос"}, {"return_value": mock.Mock(text="Ответ")})
self._ingest({"return_value": "Первый вопрос"})
conversation = self.channel.conversations.get()
for number in range(1, 6):
conversation.messages.create(author_type=MessageAuthor.CONTACT, text=f"Сообщение {number}")
self.inbound = InboundMessage(external_id="t-2", user_id="u-1", chat_id="c-1", text="Последнее", display_name="Ольга")
_, run = self._ingest({"return_value": ""}, {"return_value": mock.Mock(text="Ответ")})
history = [item["content"] for item in run.call_args.kwargs["history"]]
_, plan = self._ingest({"return_value": ""})
history = [item["content"] for item in plan.call_args.kwargs["history"]]
# Три сообщения перед новым; само новое уходит модели отдельно.
self.assertEqual(history, ["Сообщение 3", "Сообщение 4", "Сообщение 5"])
self.assertEqual(run.call_args.kwargs["message"], "Последнее")
self.assertEqual(plan.call_args.kwargs["message"], "Последнее")
class CommunicationSettingsTests(TestCase):
+15 -17
View File
@@ -4,7 +4,6 @@ from unittest import mock
from django.test import TestCase, override_settings
from chatballs.ai.models import AIAgent, AIAgentStatus
from chatballs.ai.provider.base import ProviderError
from chatballs.channels.models import Channel
from chatballs.conversations.models import (
ConnectionIdentity,
@@ -29,6 +28,7 @@ from chatballs.identity.models import (
from chatballs.integrations.models import Integration, IntegrationKind, IntegrationProvider
from chatballs.notifications.models import Notification, NotificationAudience, NotificationType
from chatballs.testing import TenantAPIClient as APIClient
from chatballs.testing import ai_answer, ai_failure, run_pending_ai_turns
def _messenger_connection(channel):
@@ -65,13 +65,11 @@ class IngestProviderFailureTests(TestCase):
from chatballs.conversations.ingest import ingest_inbound
with (
mock.patch(
"chatballs.conversations.ingest.run_channel_turn",
side_effect=ProviderError("provider is down"),
),
mock.patch("chatballs.conversations.ingest.transports.send_reply", return_value=True) as send,
ai_failure("provider is down"),
mock.patch("chatballs.conversations.transports.send_reply", return_value=True) as send,
):
ingest_inbound(self.integration, self.inbound)
run_pending_ai_turns()
conversation = self.channel.conversations.get()
# Диалог передан оператору, ответчик — оператор.
@@ -309,10 +307,11 @@ class ContactShareIngestTests(TestCase):
external_id="ext-1", user_id="u1", chat_id="c1", text="Привет", display_name="Иван", username="ivan"
)
with (
mock.patch("chatballs.conversations.ingest.run_channel_turn", return_value=mock.Mock(text="Здравствуйте!")),
mock.patch("chatballs.conversations.ingest.transports.send_reply", return_value=True),
ai_answer("Здравствуйте!"),
mock.patch("chatballs.conversations.transports.send_reply", return_value=True),
):
ingest_inbound(self.integration, inbound)
run_pending_ai_turns()
identity = ConnectionIdentity.objects.get(connection=self.integration, external_user_id="u1")
self.assertEqual(identity.username, "ivan")
@@ -323,13 +322,13 @@ class ContactShareIngestTests(TestCase):
inbound = InboundMessage(
external_id="ext-2", user_id="u1", chat_id="c1", text="", display_name="Иван", username="ivan", phone="+79991234567"
)
with (
mock.patch("chatballs.conversations.ingest.run_channel_turn") as ai_turn,
mock.patch("chatballs.conversations.ingest.transports.send_contact_ack", return_value=True) as ack,
):
with mock.patch(
"chatballs.conversations.ingest.transports.send_contact_ack", return_value=True
) as ack:
ingest_inbound(self.integration, inbound)
ai_turn.assert_not_called()
# Ход AI даже не заявлен: отвечать на присланный контакт нечего.
self.assertEqual(run_pending_ai_turns(), 0)
ack.assert_called_once()
contact = ConnectionIdentity.objects.get(connection=self.integration, external_user_id="u1").contact
self.assertEqual(contact.phone, "+79991234567")
@@ -525,6 +524,7 @@ class WebchatContactTests(TestCase):
)
response = self._post_message("Здравствуйте")
run_pending_ai_turns()
self.assertEqual(response.status_code, 201)
conversation = Conversation.objects.get(channel=self.channel)
@@ -555,11 +555,9 @@ class WebchatContactTests(TestCase):
)
def test_provider_error_hands_off_without_500(self) -> None:
with mock.patch(
"chatballs.conversations.ingest.run_channel_turn",
side_effect=ProviderError("AI недоступен"),
):
with ai_failure("AI недоступен"):
response = self._post_message("Здравствуйте")
run_pending_ai_turns()
self.assertEqual(response.status_code, 201)
conversation = Conversation.objects.get(channel=self.channel)
@@ -0,0 +1,81 @@
"""Расшифровка голосовых сообщений через BYOK-провайдера организации.
Три шага (``prepare`` → ``run`` → ``store``) вместо одной функции: обращение к
провайдеру ждёт ответа десятки секунд, и всё это время транзакция занимала бы
соединение из пула (chatballs.tenancy.middleware). Кто может разнести шаги —
разносит: ход AI (chatballs.conversations.ai_turn) и кнопка «расшифровать» в
рабочем месте (chatballs.conversations.voice_views).
"""
from __future__ import annotations
from dataclasses import dataclass
from chatballs.ai.provider.base import ProviderError
from chatballs.conversations.models import Message, TranscriptStatus
@dataclass(frozen=True, slots=True)
class TranscriptionJob:
"""Всё, что нужно провайдеру, — уже прочитанное из базы и хранилища.
Разложено на три шага (``prepare`` → ``run`` → ``store``), чтобы вызывающий
мог держать транзакцию только вокруг первого и третьего: обращение к
провайдеру ждёт ответа десятки секунд, и всё это время транзакция занимала
бы соединение из пула (chatballs.tenancy.middleware).
"""
provider: object
model: str
audio: bytes
filename: str
content_type: str
def prepare_transcription(channel, message: Message) -> TranscriptionJob | None:
"""Шаг в транзакции: провайдер организации, модель и байты аудио."""
from chatballs.ai.provider.factory import get_transcription_provider
from chatballs.ai.provider.routing import (
DEFAULT_TRANSCRIPTION_MODEL,
resolve_transcription_model,
)
if not message.audio:
return None
provider = get_transcription_provider(channel=channel)
try:
model = resolve_transcription_model(channel)
except ProviderError:
model = DEFAULT_TRANSCRIPTION_MODEL # тестовый провайдер без интеграции
with message.audio.open("rb") as handle:
audio = handle.read()
return TranscriptionJob(
provider=provider,
model=model,
audio=audio,
filename=message.audio.name.rsplit("/", 1)[-1],
content_type=message.audio_content_type or "audio/ogg",
)
def run_transcription(job: TranscriptionJob) -> str:
"""Шаг без транзакции: обращение к провайдеру."""
return job.provider.transcribe(
audio=job.audio,
filename=job.filename,
content_type=job.content_type,
model=job.model,
).strip()
def store_transcription(message: Message, transcript: str) -> None:
"""Шаг в транзакции: сохранить стенограмму и статус."""
message.transcript = transcript
message.transcript_status = TranscriptStatus.READY if transcript else TranscriptStatus.FAILED
message.save(update_fields=["transcript", "transcript_status"])
def mark_transcription_failed(message: Message) -> None:
"""Статус FAILED — оператор повторит кнопкой."""
message.transcript_status = TranscriptStatus.FAILED
message.save(update_fields=["transcript_status"])
@@ -20,9 +20,12 @@ from dataclasses import dataclass
logger = logging.getLogger(__name__)
# Первая пауза — два цикла опроса, дальше удвоение до четверти часа.
# Первая пауза — два цикла опроса, дальше удвоение до минуты. Потолок был
# четвертью часа, пока опрос и ответы AI жили в одном процессе: длинная пауза
# берегла общий цикл. Теперь опрос ничего не ждёт, а четверть часа тишины после
# одного сетевого сбоя клиент видит как «бот молчит».
FIRST_DELAY_SECONDS = 6.0
MAX_DELAY_SECONDS = 900.0
MAX_DELAY_SECONDS = 60.0
@dataclass
@@ -56,8 +59,8 @@ def record_failure(integration, error: object) -> None:
)
elif delay >= MAX_DELAY_SECONDS and (previous is None or previous.delay < MAX_DELAY_SECONDS):
logger.warning(
"%s poll keeps failing for integration %s: %s (retrying every %.0f min)",
integration.provider, integration.id, error, MAX_DELAY_SECONDS / 60,
"%s poll keeps failing for integration %s: %s (retrying every %.0fs)",
integration.provider, integration.id, error, MAX_DELAY_SECONDS,
)
@@ -94,7 +94,10 @@ def _bot_started(update: dict) -> InboundMessage | None:
def _normalize(update: dict) -> InboundMessage | None:
logger.info("MAX raw update: %s", json.dumps(update, ensure_ascii=False))
# Сырой апдейт нужен при разборе настройки, а не в каждой строке журнала
# рабочего сервера: поля MAX документированы не полностью, и посмотреть их
# глазами иногда надо — но по включённому DEBUG.
logger.debug("MAX raw update: %s", json.dumps(update, ensure_ascii=False))
update_type = update.get("update_type") or update.get("updateType")
if update_type == "bot_started":
return _bot_started(update)
@@ -75,7 +75,7 @@ class MessageTranscribeView(ConversationViewBase):
tenant_manages_own_transaction = True
def post(self, request: Request, message_id: int) -> Response:
from chatballs.conversations.ingest import (
from chatballs.conversations.transcription import (
mark_transcription_failed,
prepare_transcription,
run_transcription,
+21 -1
View File
@@ -10,11 +10,28 @@ logger = logging.getLogger(__name__)
EventHandler = Callable[[dict, TenantContext | None], None]
_REGISTRY: dict[str, EventHandler] = {}
# Обработчики, которые открывают транзакции сами (см. `register`).
_OWN_TRANSACTION: set[str] = set()
def register(event_type: str) -> Callable[[EventHandler], EventHandler]:
def register(
event_type: str, *, manages_own_transaction: bool = False
) -> Callable[[EventHandler], EventHandler]:
"""Зарегистрировать обработчик события.
По умолчанию обработчик выполняется целиком в одной транзакции: так у него
есть RLS-контекст и атомарность, и думать об этом не нужно. Обработчику,
который ходит наружу — к модели, в мессенджер, — такая транзакция стоит
соединения из пула на всё время ожидания. Он выставляет
`manages_own_transaction` и открывает `tenant_atomic` сам, вокруг обращений
к базе. Забытый блок не опасен: без транзакции RLS-настройка пуста и строки
просто не видны — ошибка проявится сразу.
"""
def decorator(handler: EventHandler) -> EventHandler:
_REGISTRY[event_type] = handler
if manages_own_transaction:
_OWN_TRANSACTION.add(event_type)
return handler
return decorator
@@ -29,5 +46,8 @@ def dispatch(event: OutboxEvent) -> None:
if context is None:
handler(event.payload, None)
return
if event.event_type in _OWN_TRANSACTION:
handler(event.payload, context)
return
with tenant_atomic(context):
handler(event.payload, context)
@@ -1,4 +1,5 @@
import logging
import os
import time
from django.core.management import call_command
@@ -11,7 +12,12 @@ from chatballs.conversations.maintenance import close_stale_conversations
from chatballs.conversations.poller import poll_all_messengers
from chatballs.events.handlers import dispatch
from chatballs.events.models import OutboxStatus
from chatballs.events.services import claim_next_outbox_event, mark_retry
from chatballs.events.services import (
OUTBOX_DB,
claim_next_outbox_event,
mark_retry,
release_stale_processing,
)
from chatballs.notifications.binding import poll_notifier_bots
from chatballs.tenancy.context import TenantActorKind, TenantContext
from chatballs.tenancy.database import tenant_atomic
@@ -20,16 +26,42 @@ from chatballs.updates.services import check_for_updates
logger = logging.getLogger(__name__)
# Роли воркера. Разделены потому, что работы у них разного веса: опрос
# подключений — это короткие запросы и записи в базу, а обработка событий может
# ждать модель десятки секунд. В одном процессе второе перекрывало первое, и
# входящие переставали забираться на всё время ответа AI.
ROLE_ALL = "all"
ROLE_POLLER = "poller"
ROLE_EVENTS = "events"
MESSENGER_POLL_INTERVAL = 3.0 # seconds between messenger long-poll cycles
MAINTENANCE_INTERVAL = 3600.0 # seconds between maintenance cycles (auto-close stale dialogs)
CALL_SWEEP_INTERVAL = 10.0 # seconds between call timeout sweeps (invite expiry, stuck connect)
# Пороги очереди задаются в минутах, поэтому раз в полминуты — с запасом:
# проверка дешёвая, а повтор гасится dedup-ключом уровня.
QUEUE_SWEEP_INTERVAL = 30.0 # seconds between waiting-queue escalation sweeps
# Возврат событий, взятых в работу упавшим процессом.
STALE_SWEEP_INTERVAL = 60.0
class Command(BaseCommand):
help = "Runs the local domain event worker (outbox dispatch + messenger inbound polling)."
help = (
"Runs the local domain event worker. Roles: 'poller' polls messenger "
"connections and runs sweeps, 'events' dispatches the outbox (AI turns), "
"'all' does both in one process (development default)."
)
def add_arguments(self, parser) -> None:
parser.add_argument(
"--role",
choices=[ROLE_ALL, ROLE_POLLER, ROLE_EVENTS],
default=os.environ.get("CHATBALLS_WORKER_ROLE", ROLE_ALL),
help=(
"Что делает этот процесс. Опрос держат в одном экземпляре "
"(курсоры подключений и паузы после сбоя живут в его памяти), "
"роль событий масштабируется репликами."
),
)
@staticmethod
def _tenant_contexts():
@@ -38,35 +70,54 @@ class Command(BaseCommand):
organization, actor_kind=TenantActorKind.SYSTEM
)
def _for_each_tenant(self, operation, failure: str) -> None:
try:
for context in self._tenant_contexts():
with tenant_atomic(context):
operation(context)
except Exception: # pragma: no cover
logger.exception(failure)
def _dispatch_one(self) -> bool:
"""Взять и обработать одно событие. False — событий нет."""
try:
event = claim_next_outbox_event()
except Exception: # pragma: no cover
# Отравленное событие не должно ронять процесс: иначе воркер
# уходит в краш-петлю и вместе с outbox встают поллинг
# мессенджеров и таймауты звонков.
logger.exception("Outbox claim cycle failed")
time.sleep(1)
return False
if event is None:
return False
try:
logger.info("Processing outbox event %s", event.id)
dispatch(event)
event.status = OutboxStatus.PROCESSED
event.processed_at = timezone.now()
event.save(
using=OUTBOX_DB,
update_fields=["status", "processed_at"],
)
except Exception as exc: # pragma: no cover
logger.exception("Outbox event failed: %s", event.id)
mark_retry(event, str(exc))
return True
def handle(self, *args: object, **options: object) -> None:
self.stdout.write("Hub worker started")
role = str(options["role"])
does_events = role in (ROLE_ALL, ROLE_EVENTS)
does_polling = role in (ROLE_ALL, ROLE_POLLER)
self.stdout.write(f"Hub worker started (role={role})")
last_poll = 0.0
last_maintenance = 0.0
last_call_sweep = 0.0
last_queue_sweep = 0.0
last_stale_sweep = 0.0
while True:
try:
event = claim_next_outbox_event()
except Exception: # pragma: no cover
# Отравленное событие не должно ронять процесс: иначе воркер
# уходит в краш-петлю и вместе с outbox встают поллинг
# мессенджеров и таймауты звонков.
logger.exception("Outbox claim cycle failed")
time.sleep(1)
continue
if event is not None:
try:
logger.info("Processing outbox event %s", event.id)
dispatch(event)
event.status = OutboxStatus.PROCESSED
event.processed_at = timezone.now()
event.save(
using="platform",
update_fields=["status", "processed_at"],
)
except Exception as exc: # pragma: no cover
logger.exception("Outbox event failed: %s", event.id)
mark_retry(event, str(exc))
worked = self._dispatch_one() if does_events else False
# Дальше идут периодические работы. Раньше обработка события
# обрывала цикл на `continue`, и при непрерывном потоке событий —
@@ -74,37 +125,27 @@ class Command(BaseCommand):
# переставали забираться входящие сообщения и истекать приглашения
# на звонки. Проверки дешёвые: почти всегда это сравнение времени.
now = time.monotonic()
if now - last_poll >= MESSENGER_POLL_INTERVAL:
if does_events and now - last_stale_sweep >= STALE_SWEEP_INTERVAL:
last_stale_sweep = now
try:
released = release_stale_processing()
if released:
logger.warning("Released %s stale outbox event(s)", released)
except Exception: # pragma: no cover
logger.exception("Stale outbox sweep failed")
if does_polling and now - last_poll >= MESSENGER_POLL_INTERVAL:
last_poll = now
try:
for context in self._tenant_contexts():
with tenant_atomic(context):
poll_all_messengers(context)
except Exception: # pragma: no cover
logger.exception("Messenger polling cycle failed")
try:
for context in self._tenant_contexts():
with tenant_atomic(context):
poll_notifier_bots(context)
except Exception: # pragma: no cover
logger.exception("Notifier polling cycle failed")
if now - last_call_sweep >= CALL_SWEEP_INTERVAL:
self._for_each_tenant(poll_all_messengers, "Messenger polling cycle failed")
self._for_each_tenant(poll_notifier_bots, "Notifier polling cycle failed")
if does_polling and now - last_call_sweep >= CALL_SWEEP_INTERVAL:
last_call_sweep = now
try:
for context in self._tenant_contexts():
with tenant_atomic(context):
expire_stale_calls(context)
except Exception: # pragma: no cover
logger.exception("Call sweep cycle failed")
if now - last_queue_sweep >= QUEUE_SWEEP_INTERVAL:
self._for_each_tenant(expire_stale_calls, "Call sweep cycle failed")
if does_polling and now - last_queue_sweep >= QUEUE_SWEEP_INTERVAL:
last_queue_sweep = now
try:
for context in self._tenant_contexts():
with tenant_atomic(context):
sweep_waiting_conversations(context)
except Exception: # pragma: no cover
logger.exception("Waiting queue sweep cycle failed")
if now - last_maintenance >= MAINTENANCE_INTERVAL:
self._for_each_tenant(
sweep_waiting_conversations, "Waiting queue sweep cycle failed"
)
if does_polling and now - last_maintenance >= MAINTENANCE_INTERVAL:
last_maintenance = now
# Канал релизов спрашивается не чаще раза в несколько часов:
# интервал держит сама проверка по времени последнего ответа.
@@ -112,12 +153,7 @@ class Command(BaseCommand):
check_for_updates()
except Exception: # pragma: no cover
logger.exception("Update check cycle failed")
try:
for context in self._tenant_contexts():
with tenant_atomic(context):
close_stale_conversations(context)
except Exception: # pragma: no cover
logger.exception("Maintenance cycle failed")
self._for_each_tenant(close_stale_conversations, "Maintenance cycle failed")
try:
# Просроченные сессии Django сам не удаляет, а их накопление
# утяжеляет карточку сотрудника: владельца сессии видно
@@ -127,5 +163,5 @@ class Command(BaseCommand):
logger.exception("Session cleanup failed")
# Спим только когда работы нет: иначе очередь событий разбиралась бы
# по одному событию в секунду.
if event is None:
if not worked:
time.sleep(1)
+47 -6
View File
@@ -3,6 +3,7 @@ from datetime import timedelta
from typing import Any
from django.db import transaction
from django.db.models import Exists, OuterRef
from django.utils import timezone
from chatballs.events.context import get_correlation_id
@@ -11,6 +12,10 @@ from chatballs.identity.models import OrganizationMembership
from chatballs.tenancy.context import TenantActorKind, TenantContext
from chatballs.tenancy.lookup import load_organization
# Где живёт outbox. Захват идёт по всем организациям сразу, поэтому читает и
# отмечает события роль platform, а не app (chatballs.tenancy.routing).
OUTBOX_DB = "platform"
@dataclass(frozen=True)
class DomainEvent:
@@ -92,24 +97,60 @@ def mark_retry(event: OutboxEvent, error: str, max_attempts: int = 5) -> None:
event.status = OutboxStatus.DEAD_LETTER if event.attempts >= max_attempts else OutboxStatus.FAILED
event.next_attempt_at = timezone.now() + timedelta(seconds=min(300, 2**event.attempts))
event.save(
using="platform",
using=OUTBOX_DB,
update_fields=["attempts", "last_error", "status", "next_attempt_at"],
)
def claim_next_outbox_event() -> OutboxEvent | None:
with transaction.atomic(using="platform"):
# Сколько событию отведено на обработку. Роль событий работает в нескольких
# процессах, и взятое в работу событие не должно достаться второму; но и
# пропасть навсегда, если процесс упал посреди обработки, оно тоже не должно.
# Срок хранится в `next_attempt_at`: у поля ровно этот смысл — «не раньше».
PROCESSING_LEASE_SECONDS = 300
def claim_next_outbox_event(*, lease_seconds: int = PROCESSING_LEASE_SECONDS) -> OutboxEvent | None:
"""Взять следующее событие в работу.
События одного объекта идут строго по очереди: пока по агрегату есть
событие в работе, следующее не выдаётся. Иначе два ответа AI одному
диалогу считались бы параллельно и приезжали клиенту вперемешку.
"""
now = timezone.now()
busy = OutboxEvent.objects.using(OUTBOX_DB).filter(
status=OutboxStatus.PROCESSING,
aggregate_type=OuterRef("aggregate_type"),
aggregate_id=OuterRef("aggregate_id"),
)
with transaction.atomic(using=OUTBOX_DB):
event = (
OutboxEvent.objects.using("platform").select_for_update(skip_locked=True)
OutboxEvent.objects.using(OUTBOX_DB).select_for_update(skip_locked=True)
.filter(
status__in=[OutboxStatus.PENDING, OutboxStatus.FAILED],
next_attempt_at__lte=timezone.now(),
next_attempt_at__lte=now,
)
.filter(~Exists(busy))
.order_by("next_attempt_at", "created_at")
.first()
)
if event is None:
return None
event.status = OutboxStatus.PROCESSING
event.save(using="platform", update_fields=["status"])
event.next_attempt_at = now + timedelta(seconds=lease_seconds)
event.save(using=OUTBOX_DB, update_fields=["status", "next_attempt_at"])
return event
def release_stale_processing() -> int:
"""Вернуть в очередь события, взятые в работу и не доведённые до конца.
Процесс мог упасть или его перезапустили между `claim` и записью
результата. Без возврата такое событие остаётся `PROCESSING` навсегда — а
вместе с ним встаёт и весь агрегат, потому что следующие события того же
объекта ждут его.
"""
return (
OutboxEvent.objects.using(OUTBOX_DB)
.filter(status=OutboxStatus.PROCESSING, next_attempt_at__lte=timezone.now())
.update(status=OutboxStatus.PENDING)
)
@@ -0,0 +1,75 @@
"""Очередь событий: порядок внутри агрегата и возврат зависших."""
from datetime import timedelta
from unittest import mock
from django.test import TestCase
from django.utils import timezone
from chatballs.events.models import EventOwnership, OutboxEvent, OutboxStatus
from chatballs.events.services import claim_next_outbox_event, release_stale_processing
from chatballs.identity.bootstrap import bootstrap_owner
from chatballs.identity.models import Organization
from chatballs.tenancy.context import TenantActorKind
class OutboxClaimTests(TestCase):
"""Захват событий.
В проде outbox читает роль platform, в тестах отдельного соединения под неё
нет — все алиасы смотрят в одну базу. Подменяется только алиас: сами
запросы те же, что в проде.
"""
def setUp(self) -> None:
bootstrap_owner(email="owner@example.com", password="temporary-password")
self.organization = Organization.objects.get(slug="demo")
patch = mock.patch("chatballs.events.services.OUTBOX_DB", "default")
patch.start()
self.addCleanup(patch.stop)
def _event(self, aggregate_id: str, event_type: str = "conversation.ai_turn_requested"):
return OutboxEvent.objects.create(
aggregate_type="Conversation",
aggregate_id=aggregate_id,
event_type=event_type,
payload={},
ownership=EventOwnership.TENANT,
organization=self.organization,
actor_kind=TenantActorKind.MACHINE,
)
def test_second_event_of_the_same_aggregate_waits(self) -> None:
# Два ответа одному диалогу не считаются параллельно: иначе они
# приезжают клиенту вперемешку.
self._event("7")
self._event("7")
first = claim_next_outbox_event()
self.assertIsNotNone(first)
self.assertIsNone(claim_next_outbox_event())
def test_other_aggregates_are_not_blocked(self) -> None:
self._event("7")
self._event("8")
self.assertIsNotNone(claim_next_outbox_event())
self.assertIsNotNone(claim_next_outbox_event())
def test_stale_processing_returns_to_the_queue(self) -> None:
# Процесс упал между взятием события и записью результата: без
# возврата оно осталось бы в работе навсегда, а вместе с ним встал бы
# весь диалог.
event = self._event("7")
claimed = claim_next_outbox_event()
self.assertEqual(claimed.id, event.id)
self.assertEqual(release_stale_processing(), 0)
OutboxEvent.objects.filter(id=event.id).update(
next_attempt_at=timezone.now() - timedelta(seconds=1)
)
self.assertEqual(release_stale_processing(), 1)
event.refresh_from_db()
self.assertEqual(event.status, OutboxStatus.PENDING)
self.assertIsNotNone(claim_next_outbox_event())
@@ -58,6 +58,7 @@ MESSAGES: dict[str, object] = {
"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",
"conversations.system.ai_handed_over": "AI handed the conversation to an operator",
"conversations.ai_unavailable_reply": "Sorry, I cannot answer right now. I have passed your question to a specialist — they will join shortly.",
"conversations.system.ai_unavailable": "AI is unavailable — the conversation was handed to an operator",
"conversations.system.call_accepted": "The customer accepted the call invitation",
"conversations.system.call_cancelled": "The operator cancelled the call invitation",
@@ -75,6 +76,7 @@ MESSAGES: dict[str, object] = {
"conversations.template_name_taken": "A template with this name already exists",
"conversations.template_not_found": "Template not found",
"conversations.template_text_required": "The template text is required",
"conversations.template_unknown_variables": "Unknown variables: {names}. Use the “Insert a variable” button to add variables.",
"conversations.unknown_label": "Unknown label",
"conversations.unknown_priority": "Unknown priority",
"conversations.voice_off_entry_point": "Voice messages are switched off for this entry point",
@@ -493,6 +495,7 @@ MESSAGES: dict[str, object] = {
"settings.email_hosts_required": "Email address, IMAP host and SMTP host are required",
"settings.email_not_a_bot": "Email cannot be a notifications bot",
"settings.inactive_channel": "Inactive channel cannot accept connections",
"settings.integration_in_use": "The integration is used by an agent or channel. Select another integration for them first.",
"settings.integration_other_organization": "Integration belongs to another organization",
"settings.mailbox_password_required": "Mailbox password is required",
"settings.name_required": "Name required",
@@ -62,6 +62,7 @@ MESSAGES: dict[str, object] = {
"conversations.system.ai_handed_over": "AI передал диалог оператору",
"conversations.system.assigned_to": "Диалог назначен на {operator}",
"conversations.system.assignment_expired": "{operator} не взял диалог — он вернулся в очередь",
"conversations.ai_unavailable_reply": "Извините, прямо сейчас не получается ответить. Я передал ваш вопрос специалисту — он скоро подключится.",
"conversations.system.ai_unavailable": "AI недоступен — диалог передан оператору",
"conversations.system.call_accepted": "Клиент принял приглашение на звонок",
"conversations.system.call_cancelled": "Сотрудник отменил приглашение на звонок",
@@ -79,6 +80,7 @@ MESSAGES: dict[str, object] = {
"conversations.template_name_taken": "Шаблон с таким названием уже есть",
"conversations.template_not_found": "Шаблон не найден",
"conversations.template_text_required": "Текст шаблона обязателен",
"conversations.template_unknown_variables": "Неизвестные переменные: {names}. Вставляйте переменные кнопкой «Вставить переменную».",
"conversations.unknown_label": "Неизвестная метка",
"conversations.unknown_priority": "Неизвестный приоритет",
"conversations.voice_off_entry_point": "Голосовые отключены для этой точки входа",
@@ -497,6 +499,7 @@ MESSAGES: dict[str, object] = {
"settings.email_hosts_required": "Укажите адрес, IMAP- и SMTP-сервер",
"settings.email_not_a_bot": "Почта не может быть ботом уведомлений",
"settings.inactive_channel": "Неактивный канал не принимает подключения",
"settings.integration_in_use": "Интеграция используется агентом или каналом. Сначала выберите для них другую интеграцию.",
"settings.integration_other_organization": "Интеграция принадлежит другой организации",
"settings.mailbox_password_required": "Укажите пароль ящика",
"settings.name_required": "Укажите название",
@@ -189,7 +189,7 @@
"replyTemplates": [
{
"title": "Greeting",
"text": "Hello! My name is {name}, I am a consultant at Nord Atelier. Could you tell me the track width and the window drop — I will work out a quote."
"text": "Hello! My name is {{operator_name}}, I am a consultant at Nord Atelier. Could you tell me the track width and the window drop — I will work out a quote."
},
{
"title": "Lead times",
@@ -189,7 +189,7 @@
"replyTemplates": [
{
"title": "Приветствие",
"text": "Здравствуйте! Меня зовут {имя}, я консультант ателье «Норд». Подскажите, пожалуйста, ширину карниза и высоту окна — сделаю расчёт."
"text": "Здравствуйте! Меня зовут {{operator_name}}, я консультант ателье «Норд». Подскажите, пожалуйста, ширину карниза и высоту окна — сделаю расчёт."
},
{
"title": "Сроки пошива",
@@ -0,0 +1,78 @@
from django.core.exceptions import ValidationError
from django.utils import timezone
from chatballs.i18n import t
from chatballs.integrations import checks
from chatballs.integrations.models import Integration, IntegrationProvider, IntegrationStatus
from chatballs.integrations.runtime import advance_revision_after_successful_check
from chatballs.tenancy.context import TenantContext
_CHECKS = {
IntegrationProvider.OPENROUTER: checks.check_openrouter,
IntegrationProvider.CUSTOM: checks.check_custom,
IntegrationProvider.DEMO: checks.check_demo,
IntegrationProvider.MAX: checks.check_max,
IntegrationProvider.TELEGRAM: checks.check_telegram,
IntegrationProvider.VK: checks.check_vk,
}
def _check_web(context: TenantContext, integration: Integration) -> tuple[bool, str, dict]:
"""Проверить конфигурацию собственного Web-виджета без внешнего API."""
if integration.channel_id is None:
return False, t("integrations.check_web_not_bound"), {}
from chatballs.webchat.widgets import ensure_widget
try:
widget = ensure_widget(integration)
except ValidationError as error:
return False, "; ".join(error.messages), {}
if widget is None:
return False, t("integrations.check_web_no_config"), {}
if not widget.allowed_origins:
return False, t("integrations.check_web_no_origins"), {}
return True, t("integrations.check_web_active", channel=integration.channel.name), {}
def test_integration(*, context: TenantContext, integration: Integration) -> Integration:
if integration.organization_id != context.organization_id:
raise ValidationError({"integration": t("settings.integration_other_organization")})
if integration.provider == IntegrationProvider.WEB:
ok, detail, meta = _check_web(context, integration)
elif integration.provider == IntegrationProvider.EMAIL:
ok, detail, meta = checks.check_email(
secret=integration.secret,
config=integration.config,
)
else:
check = _CHECKS.get(integration.provider)
if check is None:
ok, detail, meta = False, t("integrations.check_unsupported"), {}
else:
ok, detail, meta = check(
secret=integration.secret,
base_url=str(integration.config.get("base_url", "")),
proxy_url=str(integration.config.get("proxy_url", "")),
)
integration.status = IntegrationStatus.OK if ok else IntegrationStatus.ERROR
integration.last_error = "" if ok else detail
integration.last_checked_at = timezone.now()
update_fields = ["status", "last_error", "last_checked_at", "updated_at"]
if ok and advance_revision_after_successful_check(integration):
# API и event-workers — разные процессы; ревизия инвалидирует их breaker.
update_fields.append("runtime_revision")
if ok and meta:
config = {**integration.config}
for key in ("bot_id", "bot_username", "bot_name"):
if meta.get(key):
config[key] = meta[key]
if config != integration.config:
integration.config = config
update_fields.append("config")
integration.save(update_fields=update_fields)
if integration.provider == IntegrationProvider.WEB:
from chatballs.webchat.widgets import sync_widget_check_status
sync_widget_check_status(integration, ok=ok)
return integration
@@ -0,0 +1,19 @@
from django.core.exceptions import ValidationError
from django.db.models.deletion import ProtectedError
from chatballs.i18n import t
from chatballs.integrations.models import Integration
from chatballs.tenancy.context import TenantContext
class IntegrationInUse(Exception):
"""Интеграция связана с агентом или каналом и не может быть удалена."""
def delete_integration(*, context: TenantContext, integration: Integration) -> None:
if integration.organization_id != context.organization_id:
raise ValidationError({"integration": t("settings.integration_other_organization")})
try:
integration.delete()
except ProtectedError as error:
raise IntegrationInUse(t("settings.integration_in_use")) from error
@@ -0,0 +1,15 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("integrations", "0009_integration_vk"),
]
operations = [
migrations.AddField(
model_name="integration",
name="runtime_revision",
field=models.PositiveBigIntegerField(default=1),
),
]
@@ -69,6 +69,9 @@ class Integration(models.Model):
poll_marker = models.CharField(max_length=64, blank=True)
last_checked_at = models.DateTimeField(null=True, blank=True)
last_error = models.TextField(blank=True)
# Версия runtime-настроек LLM. Event-workers держат circuit breaker в своей
# памяти и заменяют его после исправления конфигурации провайдера.
runtime_revision = models.PositiveBigIntegerField(default=1)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
@@ -0,0 +1,20 @@
from chatballs.integrations.models import Integration, IntegrationKind
def advance_revision_after_configuration_change(
integration: Integration,
*,
previous_config: dict,
previous_secret: str,
) -> None:
if integration.kind == IntegrationKind.LLM_PROVIDER and (
integration.config != previous_config or integration.secret != previous_secret
):
integration.runtime_revision += 1
def advance_revision_after_successful_check(integration: Integration) -> bool:
if integration.kind != IntegrationKind.LLM_PROVIDER:
return False
integration.runtime_revision += 1
return True
+13 -78
View File
@@ -2,11 +2,10 @@ from dataclasses import dataclass, field
from django.core.exceptions import ValidationError
from django.db import transaction
from django.utils import timezone
from chatballs.i18n import t
from chatballs.identity.models import Organization
from chatballs.integrations import checks
from chatballs.integrations.checking import test_integration as test_integration
from chatballs.integrations.models import (
PROVIDER_KIND,
Integration,
@@ -19,6 +18,9 @@ from chatballs.integrations.outbound import (
OutboundUrlRejected,
clean_config_url,
)
from chatballs.integrations.runtime import (
advance_revision_after_configuration_change,
)
from chatballs.tenancy.context import TenantContext
@@ -195,15 +197,17 @@ def create_integration(*, context: TenantContext, data: IntegrationInput) -> Int
_publish_web_widget(context=context, integration=integration)
return integration
@transaction.atomic
def update_integration(
*, context: TenantContext, integration: Integration, data: IntegrationInput
) -> Integration:
if integration.organization_id != context.organization_id:
raise ValidationError({"integration": t("settings.integration_other_organization")})
previous_config = integration.config
previous_secret = integration.secret
normalized_config = _normalized_config(integration.provider, data.config)
integration.name = data.name.strip() or integration.name
integration.config = _normalized_config(integration.provider, data.config)
integration.config = normalized_config
integration.channel = _resolve_channel(
integration.organization,
data.channel_id,
@@ -214,6 +218,11 @@ def update_integration(
# Пустой/отсутствующий секрет при обновлении не затирает существующий.
if data.secret:
integration.secret = data.secret.strip()
advance_revision_after_configuration_change(
integration,
previous_config=previous_config,
previous_secret=previous_secret,
)
integration.status = IntegrationStatus.UNCHECKED
integration.last_checked_at = None
integration.last_error = ""
@@ -222,77 +231,3 @@ def update_integration(
if integration.provider == IntegrationProvider.WEB:
_publish_web_widget(context=context, integration=integration)
return integration
def delete_integration(*, context: TenantContext, integration: Integration) -> None:
if integration.organization_id != context.organization_id:
raise ValidationError({"integration": t("settings.integration_other_organization")})
integration.delete()
_CHECKS = {
IntegrationProvider.OPENROUTER: checks.check_openrouter,
IntegrationProvider.CUSTOM: checks.check_custom,
IntegrationProvider.DEMO: checks.check_demo,
IntegrationProvider.MAX: checks.check_max,
IntegrationProvider.TELEGRAM: checks.check_telegram,
IntegrationProvider.VK: checks.check_vk,
}
def _check_web(context: TenantContext, integration: Integration) -> tuple[bool, str, dict]:
"""Web-виджет обслуживается нашим же backend'ом — внешнего API нет.
Проверяем конфигурацию конкретного widget entry point."""
if integration.channel_id is None:
return False, t("integrations.check_web_not_bound"), {}
from chatballs.webchat.widgets import ensure_widget
try:
widget = ensure_widget(integration)
except ValidationError as error:
return False, "; ".join(error.messages), {}
if widget is None:
return False, t("integrations.check_web_no_config"), {}
# Пустой allowed_origins в проде запрещает вообще все домены (webchat.services.
# origin_allowed), и на сайте виджет молча показывает «Чат временно недоступен».
# Проверка обязана падать здесь, а не оставлять зелёный статус при мёртвом чате.
if not widget.allowed_origins:
return False, t("integrations.check_web_no_origins"), {}
return True, t("integrations.check_web_active", channel=integration.channel.name), {}
def test_integration(*, context: TenantContext, integration: Integration) -> Integration:
if integration.organization_id != context.organization_id:
raise ValidationError({"integration": t("settings.integration_other_organization")})
if integration.provider == IntegrationProvider.WEB:
ok, detail, meta = _check_web(context, integration)
elif integration.provider == IntegrationProvider.EMAIL:
# Email: сигнатура шире общей (нужен весь config), диспетчеризуется отдельно.
ok, detail, meta = checks.check_email(secret=integration.secret, config=integration.config)
else:
check = _CHECKS.get(integration.provider)
if check is None:
ok, detail, meta = False, t("integrations.check_unsupported"), {}
else:
ok, detail, meta = check(secret=integration.secret, base_url=str(integration.config.get("base_url", "")), proxy_url=str(integration.config.get("proxy_url", "")))
integration.status = IntegrationStatus.OK if ok else IntegrationStatus.ERROR
# Диагностика сохраняется на языке того, кто нажал «Проверить»: она живёт до
# следующей проверки, и хранить её кодом, как историю диалога, нечего.
integration.last_error = "" if ok else detail
integration.last_checked_at = timezone.now()
update_fields = ["status", "last_error", "last_checked_at", "updated_at"]
# Идентичность бота (id/username/имя) — из ответа API, авторитетный источник.
if ok and meta:
config = {**integration.config}
for key in ("bot_id", "bot_username", "bot_name"):
if meta.get(key):
config[key] = meta[key]
if config != integration.config:
integration.config = config
update_fields.append("config")
integration.save(update_fields=update_fields)
if integration.provider == IntegrationProvider.WEB:
from chatballs.webchat.widgets import sync_widget_check_status
sync_widget_check_status(integration, ok=ok)
return integration
@@ -0,0 +1,119 @@
from unittest import mock
from django.test import TestCase
from chatballs.ai.models import AIAgent
from chatballs.channels.models import Channel
from chatballs.identity.bootstrap import bootstrap_owner
from chatballs.identity.models import AuditEvent, Organization
from chatballs.integrations import checking
from chatballs.integrations.models import Integration, IntegrationProvider
from chatballs.integrations.services import (
IntegrationInput,
create_integration,
test_integration,
update_integration,
)
from chatballs.testing import system_tenant_context
def custom_input(*, name: str = "Мой провайдер", secret: str | None = "sk-custom", base_url: str = "https://api.example/v1") -> IntegrationInput:
return IntegrationInput(
provider=IntegrationProvider.CUSTOM,
name=name,
secret=secret,
config={"baseUrl": base_url, "defaultModel": "model"},
)
class ProviderRuntimeRevisionTests(TestCase):
def setUp(self) -> None:
bootstrap_owner(email="owner@example.com", password="temporary-password")
self.organization = Organization.objects.get(slug="demo")
self.context = system_tenant_context(self.organization)
def test_runtime_change_advances_revision_but_rename_does_not(self) -> None:
integration = create_integration(context=self.context, data=custom_input(secret="sk-old"))
initial_revision = integration.runtime_revision
renamed = update_integration(
context=self.context,
integration=integration,
data=custom_input(
name="Новое имя",
secret=None,
),
)
self.assertEqual(renamed.runtime_revision, initial_revision)
updated = update_integration(
context=self.context,
integration=renamed,
data=custom_input(name=renamed.name, secret="sk-new", base_url="https://new.example/v1"),
)
self.assertEqual(updated.runtime_revision, initial_revision + 1)
def test_successful_check_advances_runtime_revision(self) -> None:
integration = create_integration(context=self.context, data=custom_input())
initial_revision = integration.runtime_revision
with mock.patch.dict(
checking._CHECKS,
{IntegrationProvider.CUSTOM: lambda **_kwargs: (True, "ok", {})},
):
checked = test_integration(context=self.context, integration=integration)
self.assertEqual(checked.runtime_revision, initial_revision + 1)
class IntegrationDeletionTests(TestCase):
def setUp(self) -> None:
bootstrap_owner(email="owner@example.com", password="temporary-password")
self.organization = Organization.objects.get(slug="demo")
self.context = system_tenant_context(self.organization)
self.client.login(username="owner@example.com", password="temporary-password")
def _url(self, integration_id: int) -> str:
return f"/api/v1/organizations/{self.organization.public_id}/integrations/{integration_id}/"
def test_used_provider_returns_conflict_and_is_not_audited_as_deleted(self) -> None:
integration = create_integration(context=self.context, data=custom_input())
channel = Channel.objects.create(
organization=self.organization,
code="protected-provider",
name="Канал",
)
AIAgent.objects.create(
channel=channel,
name="Агент",
provider_integration=integration,
)
response = self.client.delete(self._url(integration.id))
self.assertEqual(response.status_code, 409)
self.assertIn("используется", response.json()["detail"])
self.assertTrue(Integration.objects.filter(id=integration.id).exists())
self.assertFalse(
AuditEvent.objects.filter(
action="integrations.integration_deleted",
object_id=str(integration.id),
).exists()
)
def test_unused_provider_is_deleted_and_audited_with_original_id(self) -> None:
integration = create_integration(
context=self.context,
data=custom_input(name="Свободный провайдер"),
)
integration_id = integration.id
response = self.client.delete(self._url(integration_id))
self.assertEqual(response.status_code, 204)
self.assertFalse(Integration.objects.filter(id=integration_id).exists())
self.assertTrue(
AuditEvent.objects.filter(
action="integrations.integration_deleted",
object_id=str(integration_id),
).exists()
)
+20 -5
View File
@@ -7,6 +7,7 @@ from rest_framework.views import APIView
from chatballs.api.permissions import HasCapability
from chatballs.i18n import t
from chatballs.identity.audit import record_audit_event
from chatballs.integrations.deletion import IntegrationInUse, delete_integration
from chatballs.integrations.models import Integration
from chatballs.integrations.selectors import (
integration_for_context,
@@ -16,7 +17,6 @@ from chatballs.integrations.serializers import integration_payload, restore_prox
from chatballs.integrations.services import (
IntegrationInput,
create_integration,
delete_integration,
test_integration,
update_integration,
)
@@ -68,13 +68,19 @@ def _validation_error(error: Exception) -> Response:
return Response({"detail": detail}, status=400)
def _audit(request: Request, action: str, integration: Integration) -> None:
def _audit(
request: Request,
action: str,
integration: Integration,
*,
object_id: int | None = None,
) -> None:
record_audit_event(
action=action,
actor=request.user,
organization=request.tenant_context.organization,
object_type="Integration",
object_id=str(integration.id),
object_id=str(integration.id if object_id is None else object_id),
request=request,
)
@@ -127,8 +133,17 @@ class IntegrationDetailView(APIView):
integration = self._get(request, integration_id)
except Integration.DoesNotExist:
return Response({"detail": t("settings.integration_not_found")}, status=404)
_audit(request, "integrations.integration_deleted", integration)
delete_integration(context=request.tenant_context, integration=integration)
integration_id = integration.id
try:
delete_integration(context=request.tenant_context, integration=integration)
except IntegrationInUse as error:
return Response({"detail": str(error)}, status=409)
_audit(
request,
"integrations.integration_deleted",
integration,
object_id=integration_id,
)
return Response(status=204)
@@ -0,0 +1,31 @@
from django.core.exceptions import ValidationError
from chatballs.i18n import t
from chatballs.support_portals.models import PortalArticle
from chatballs.support_portals.statuses import ArticleStatus
def apply_article_curation(article: PortalArticle, data: dict) -> None:
if "sortOrder" in data:
article.sort_order = int(data["sortOrder"])
if "relatedArticleIds" not in data:
return
related_ids = data["relatedArticleIds"]
if (
not isinstance(related_ids, list)
or len(related_ids) > 5
or any(type(item) is not int for item in related_ids)
or len(set(related_ids)) != len(related_ids)
or article.id in related_ids
):
raise ValidationError({"relatedArticleIds": t("portals.invalid_article_data")})
valid_count = PortalArticle.objects.filter(
id__in=related_ids,
portal=article.portal,
locale=article.locale,
status=ArticleStatus.PUBLISHED,
published_revision__isnull=False,
).count()
if valid_count != len(related_ids):
raise ValidationError({"relatedArticleIds": t("portals.invalid_article_data")})
article.related_article_ids = related_ids
@@ -9,6 +9,7 @@ from django.utils.text import slugify
from chatballs.ai.indexing import reindex_portal_article
from chatballs.conversations.transports.base import guess_content_type, safe_filename
from chatballs.i18n import t
from chatballs.support_portals.article_curation import apply_article_curation
from chatballs.support_portals.content_markdown import normalize_file_links
from chatballs.support_portals.models import (
PortalArticle,
@@ -104,6 +105,7 @@ def create_article(
category=category,
slug=str(data.get("slug", "")).strip().lower(),
locale=str(data.get("locale", portal.default_locale)).strip().lower(),
sort_order=1000,
)
article.full_clean()
article.save()
@@ -143,8 +145,11 @@ def update_article(
)
article.slug = str(data.get("slug", article.slug)).strip().lower()
article.locale = str(data.get("locale", article.locale)).strip().lower()
apply_article_curation(article, data)
article.full_clean()
article.save(update_fields=["category", "slug", "locale", "updated_at"])
article.save(update_fields=[
"category", "slug", "locale", "sort_order", "related_article_ids", "updated_at",
])
return article
Whitespace-only changes.
Whitespace-only changes.
@@ -0,0 +1,96 @@
import json
import sys
from pathlib import Path
from django.core.management.base import BaseCommand, CommandError
from chatballs.support_portals.models import PortalArticle, SupportPortal
from chatballs.tenancy.context import TenantContext
from chatballs.tenancy.database import tenant_atomic
from chatballs.tenancy.ingress import support_portal_route
from chatballs.tenancy.lookup import load_organization
def checked_changes(portal: SupportPortal, plan: dict) -> list[PortalArticle]:
if plan.get("portal") != portal.slug:
raise CommandError("Portal slug differs from the curation plan")
locale = plan.get("locale")
order = plan.get("order")
related = plan.get("related")
if not isinstance(locale, str) or not isinstance(order, dict) or not isinstance(related, dict):
raise CommandError("Invalid curation plan")
articles = list(
portal.articles.filter(
locale=locale, status="PUBLISHED", published_revision__isnull=False
).select_related("category")
)
by_slug = {item.slug: item for item in articles}
ordered = [slug for slugs in order.values() for slug in slugs]
if (
len(ordered) != len(by_slug)
or len(set(ordered)) != len(ordered)
or set(ordered) != set(by_slug)
or set(related) != set(by_slug)
):
raise CommandError("Plan must cover every published article exactly once")
for category_slug, slugs in order.items():
if not isinstance(slugs, list) or any(
by_slug[slug].category.slug != category_slug for slug in slugs
):
raise CommandError(f"Wrong article category in {category_slug}")
for slug, targets in related.items():
if (
not isinstance(targets, list)
or len(targets) > 5
or len(set(targets)) != len(targets)
or slug in targets
or not set(targets) <= set(by_slug)
):
raise CommandError(f"Invalid related articles for {slug}")
changed = []
for slugs in order.values():
for position, slug in enumerate(slugs, start=1):
article = by_slug[slug]
target_ids = [by_slug[target].id for target in related[slug]]
if article.sort_order != position or article.related_article_ids != target_ids:
article.sort_order = position
article.related_article_ids = target_ids
changed.append(article)
return changed
class Command(BaseCommand):
help = "Validate or apply an editorial order and related articles to a public portal"
def add_arguments(self, parser):
parser.add_argument("--host", required=True)
parser.add_argument("--file", required=True, help="JSON plan path or - for stdin")
parser.add_argument("--apply", action="store_true")
def handle(self, *args, **options):
hostname = options["host"].strip().lower().rstrip(".")
route = support_portal_route(hostname)
organization = load_organization(route.organization_id) if route else None
if organization is None:
raise CommandError("Public portal host not found")
source = sys.stdin.read() if options["file"] == "-" else Path(options["file"]).read_text(encoding="utf-8")
try:
plan = json.loads(source)
except json.JSONDecodeError as error:
raise CommandError("Invalid JSON plan") from error
context = TenantContext.for_resource(organization)
with tenant_atomic(context):
portal = SupportPortal.objects.filter(
id=route.resource_id,
organization=organization,
status="PUBLISHED",
).first()
if portal is None:
raise CommandError("Published portal not found")
changed = checked_changes(portal, plan)
if options["apply"] and changed:
PortalArticle.objects.bulk_update(
changed, ["sort_order", "related_article_ids"]
)
action = "Updated" if options["apply"] else "Would update"
self.stdout.write(f"{action} {len(changed)} articles")
@@ -0,0 +1,20 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("support_portals", "0011_hosted_domains_follow_installation"),
]
operations = [
migrations.AddField(
model_name="portalarticle",
name="sort_order",
field=models.PositiveIntegerField(default=1000),
),
migrations.AddField(
model_name="portalarticle",
name="related_article_ids",
field=models.JSONField(blank=True, default=list),
),
]
@@ -153,6 +153,8 @@ class PortalArticle(TenantRelationModel):
)
slug = models.SlugField(max_length=96)
locale = models.CharField(max_length=16, default="ru")
sort_order = models.PositiveIntegerField(default=1000)
related_article_ids = models.JSONField(default=list, blank=True)
status = models.CharField(
max_length=16,
choices=ArticleStatus.choices,
@@ -58,7 +58,9 @@ class PublicPortalDetailView(PublicPortalView):
return Response({"detail": t("portals.not_found")}, status=404)
context, portal = resolved
with tenant_atomic(context):
counts = category_article_counts(portal, published_only=True)
counts = category_article_counts(
portal, published_only=True, locale=portal.default_locale
)
return Response(
{
"portal": public_portal_payload(portal),
@@ -85,6 +87,7 @@ class PublicArticleListView(PublicPortalView):
locale=locale,
category=str(request.query_params.get("category", "")),
query=str(request.query_params.get("q", "")).strip(),
direct_category=request.query_params.get("direct") == "1",
)
try:
limit = min(max(int(request.query_params.get("limit", 50)), 1), 100)
@@ -122,7 +125,17 @@ class PublicArticleDetailView(PublicPortalView):
).first()
if article is None:
return Response({"detail": t("portals.article_not_found")}, status=404)
return Response({"article": public_article_payload(article)})
payload = public_article_payload(article)
related = public_articles(portal, locale=locale).filter(
id__in=article.related_article_ids
)
by_id = {item.id: item for item in related}
payload["relatedArticles"] = [
public_article_payload(by_id[item_id], content=False)
for item_id in article.related_article_ids
if item_id in by_id
]
return Response({"article": payload})
class PublicArticleFeedbackView(PublicPortalView):
@@ -108,6 +108,7 @@ def public_articles(
locale: str,
category: str = "",
query: str = "",
direct_category: bool = False,
) -> QuerySet[PortalArticle]:
articles = (
PortalArticle.objects.filter(
@@ -118,13 +119,13 @@ def public_articles(
)
.select_related("category", "published_revision")
.prefetch_related("files")
.order_by("category__sort_order", "published_revision__title")
.order_by("category__sort_order", "sort_order", "published_revision__title", "id")
)
if category:
root = portal.categories.filter(slug=category).first()
if root is None:
return articles.none()
category_ids = descendant_category_ids(portal, root.id)
category_ids = {root.id} if direct_category else descendant_category_ids(portal, root.id)
articles = articles.filter(category_id__in=category_ids)
if query:
articles = articles.filter(
@@ -151,10 +152,12 @@ def descendant_category_ids(portal: SupportPortal, root_id: int) -> set[int]:
def category_article_counts(
portal: SupportPortal, *, published_only: bool = False
portal: SupportPortal, *, published_only: bool = False, locale: str | None = None
) -> dict[int, int]:
categories = list(portal.categories.all())
direct_query = PortalArticle.objects.filter(portal=portal)
if locale is not None:
direct_query = direct_query.filter(locale=locale)
if published_only:
direct_query = direct_query.filter(
status="PUBLISHED", published_revision__isnull=False
@@ -107,6 +107,8 @@ def article_payload(
"id": article.id,
"slug": article.slug,
"locale": article.locale,
"sortOrder": article.sort_order,
"relatedArticleIds": article.related_article_ids,
"status": article.status,
"category": category_payload(article.category),
"publishedRevision": (
@@ -0,0 +1,139 @@
import json
from io import StringIO
from unittest.mock import patch
from django.core.management import call_command
from chatballs.support_portals.tests.base import SupportPortalTestCase
class ArticleCurationTests(SupportPortalTestCase):
def setUp(self) -> None:
super().setUp()
portal = self.create_portal().json()["portal"]
self.portal_id = portal["id"]
self.portal_host = portal["hostedDomain"]
self.root = self._category("Начало", "start")
self.child = self._category("Продолжение", "next", parent_id=self.root)
def _category(self, name: str, slug: str, *, parent_id: int | None = None) -> int:
payload = {"name": name, "slug": slug}
if parent_id is not None:
payload["parentId"] = parent_id
response = self.client.post(
f"/api/v1/support/portals/{self.portal_id}/categories/",
payload,
format="json",
)
self.assertEqual(response.status_code, 201, response.content)
return response.json()["category"]["id"]
def _article(self, slug: str, title: str, category_id: int) -> int:
response = self.client.post(
f"/api/v1/support/portals/{self.portal_id}/articles/",
{
"categoryId": category_id,
"slug": slug,
"title": title,
"summary": title,
"content": title,
},
format="json",
)
self.assertEqual(response.status_code, 201, response.content)
article = response.json()["article"]
published = self.client.post(
f"/api/v1/support/portals/{self.portal_id}/articles/{article['id']}/publish/",
{"revisionId": article["revisions"][0]["id"]},
format="json",
)
self.assertEqual(published.status_code, 200, published.content)
return article["id"]
def test_order_related_articles_and_direct_category(self) -> None:
first = self._article("first", "Альфа", self.root)
second = self._article("second", "Бета", self.root)
child = self._article("child", "Гамма", self.child)
base = f"/api/v1/support/portals/{self.portal_id}/articles/"
for article_id, data in (
(first, {"sortOrder": 2, "relatedArticleIds": [child, second]}),
(second, {"sortOrder": 1}),
):
response = self.client.patch(
f"{base}{article_id}/", data, format="json"
)
self.assertEqual(response.status_code, 200, response.content)
invalid = self.client.patch(
f"{base}{first}/",
{"relatedArticleIds": [first]},
format="json",
)
self.assertEqual(invalid.status_code, 400)
self.client.post(
f"/api/v1/support/portals/{self.portal_id}/status/",
{"status": "PUBLISHED"},
format="json",
)
self.client.logout()
listing = self.client.get(
"/api/v1/help/articles/?category=start&direct=1",
HTTP_HOST=self.portal_host,
)
self.assertEqual(listing.status_code, 200, listing.content)
self.assertEqual(
[item["slug"] for item in listing.json()["items"]],
["second", "first"],
)
detail = self.client.get(
"/api/v1/help/articles/first/", HTTP_HOST=self.portal_host
)
self.assertEqual(detail.status_code, 200, detail.content)
self.assertEqual(
[item["slug"] for item in detail.json()["article"]["relatedArticles"]],
["child", "second"],
)
def test_curation_command_dry_run_and_apply(self) -> None:
first = self._article("first", "Альфа", self.root)
second = self._article("second", "Бета", self.root)
self.client.post(
f"/api/v1/support/portals/{self.portal_id}/status/",
{"status": "PUBLISHED"},
format="json",
)
plan = json.dumps({
"portal": "app-help",
"locale": "ru",
"order": {"start": ["second", "first"]},
"related": {"first": ["second"], "second": ["first"]},
})
output = StringIO()
with patch("sys.stdin", StringIO(plan)):
call_command(
"apply_portal_curation", host=self.portal_host, file="-", stdout=output
)
self.assertIn("Would update 2 articles", output.getvalue())
output = StringIO()
with patch("sys.stdin", StringIO(plan)):
call_command(
"apply_portal_curation", host=self.portal_host, file="-", apply=True,
stdout=output,
)
self.assertIn("Updated 2 articles", output.getvalue())
self.client.logout()
listing = self.client.get(
"/api/v1/help/articles/?category=start&direct=1",
HTTP_HOST=self.portal_host,
)
self.assertEqual(
[item["slug"] for item in listing.json()["items"]],
["second", "first"],
)
detail = self.client.get(
"/api/v1/help/articles/first/", HTTP_HOST=self.portal_host
)
self.assertEqual(
[item["slug"] for item in detail.json()["article"]["relatedArticles"]],
["second"],
)
self.assertNotEqual(first, second)
+51
View File
@@ -23,6 +23,57 @@ def system_tenant_context(organization: Organization) -> TenantContext:
return TenantContext.for_resource(organization, actor_kind=TenantActorKind.SYSTEM)
def run_pending_ai_turns() -> int:
"""Прогнать поставленные ходы AI и вернуть их число.
Ход считается ролью событий воркера, которой в тестах нет: приём ставит
заявку, а вызвать её должен сам тест — так же, как он это делает с
приглашениями на звонок и письмами.
"""
from chatballs.conversations.ai_turn import AI_TURN_REQUESTED
from chatballs.events.handlers import dispatch
from chatballs.events.models import OutboxEvent, OutboxStatus
events = list(
OutboxEvent.objects.filter(
event_type=AI_TURN_REQUESTED, status=OutboxStatus.PENDING
).order_by("created_at")
)
for event in events:
dispatch(event)
event.status = OutboxStatus.PROCESSED
event.save(update_fields=["status"])
return len(events)
def ai_answer(text: str):
"""Подменить ответ модели в ходе AI (контекст-менеджер)."""
from unittest import mock
from chatballs.ai.provider.base import ChatResult
from chatballs.ai.turn import TurnAnswer
return mock.patch(
"chatballs.conversations.ai_turn.run_turn_chat",
return_value=TurnAnswer(
result=ChatResult(text=text, model="test", prompt_tokens=1, completion_tokens=1)
),
)
def ai_failure(message: str = "provider is down"):
"""Подменить ход AI отказом провайдера (контекст-менеджер)."""
from unittest import mock
from chatballs.ai.provider.base import ProviderError
from chatballs.ai.turn import TurnAnswer
return mock.patch(
"chatballs.conversations.ai_turn.run_turn_chat",
return_value=TurnAnswer(error=ProviderError(message)),
)
class TenantAPIClient(APIClient):
"""Test client that turns legacy test literals into the C03 tenant route.
+201 -11
View File
@@ -21,22 +21,27 @@ LOADER_JS = r"""
: "channel=" + encodeURIComponent(legacyChannel);
var panelUrl = origin + "/chat/?" + entryQuery + "&instanceId=" + encodeURIComponent(instanceId);
var open = false, frame = null, unread = false, callActive = false;
var open = false, frame = null, shell = null, unread = false, callActive = false, expanded = false;
var contentTimer = 0;
var api = window.ChatballsChat = window.ChatballsChat || {};
window.ChatballsChat = api; // legacy alias для уже встроенных хостов
var style = document.createElement("style");
style.textContent = "@keyframes chatballs-chat-message-bump{0%,100%{transform:translateY(0)}35%{transform:translateY(-6px)}70%{transform:translateY(-2px)}}@keyframes chatballs-chat-call-shake{0%,18%,100%{transform:translateX(0)}3%{transform:translateX(-5px)}6%{transform:translateX(5px)}9%{transform:translateX(-4px)}12%{transform:translateX(4px)}15%{transform:translateX(-2px)}}.chatballs-chat-message-bump{animation:chatballs-chat-message-bump .42s ease-out}.chatballs-chat-call-shake{animation:chatballs-chat-call-shake 3.2s ease-in-out infinite}.chatballs-chat-launcher:hover{transform:translateY(-2px);box-shadow:0 12px 30px rgba(22,119,255,0.45)}.chatballs-chat-launcher:focus-visible{outline:3px solid rgba(22,119,255,0.45);outline-offset:2px}";
style.textContent = "@keyframes chatballs-chat-pop{from{opacity:0;transform:scale(.55) rotate(-25deg)}to{opacity:1;transform:none}}.chatballs-chat-launcher svg{animation:chatballs-chat-pop .24s cubic-bezier(.16,1,.3,1)}@keyframes chatballs-chat-blink{0%,92%,100%{transform:scaleY(1)}95%{transform:scaleY(.12)}}.chatballs-chat-eyes{transform-origin:24px 20px;animation:chatballs-chat-blink 4.2s infinite}@keyframes chatballs-chat-message-bump{0%,100%{transform:translateY(0)}35%{transform:translateY(-6px)}70%{transform:translateY(-2px)}}@keyframes chatballs-chat-call-shake{0%,18%,100%{transform:translateX(0)}3%{transform:translateX(-5px)}6%{transform:translateX(5px)}9%{transform:translateX(-4px)}12%{transform:translateX(4px)}15%{transform:translateX(-2px)}}.chatballs-chat-message-bump{animation:chatballs-chat-message-bump .42s ease-out}.chatballs-chat-call-shake{animation:chatballs-chat-call-shake 3.2s ease-in-out infinite}.chatballs-chat-launcher:hover{transform:translateY(-2px);box-shadow:0 12px 30px rgba(22,119,255,0.45)}.chatballs-chat-launcher:focus-visible{outline:3px solid rgba(22,119,255,0.45);outline-offset:2px}";
(document.head || document.documentElement).appendChild(style);
// Launcher — «пилюля» с иконкой (Tabler brand-hipchat) и подписью. Иконка
// вшита как inline SVG: на сайте продукта шрифта Tabler нет.
// Launcher — круглая кнопка со знаком агента. Знак вшит как inline SVG:
// на сайте, где стоит виджет, наших файлов нет. Пока панель открыта, кнопка
// остаётся на месте и показывает шеврон — им же панель и закрывают.
var BOT_ICON = '<svg viewBox="0 0 48 48" width="30" height="30" fill="#fff" style="flex:none"><mask id="chatballs-launcher-face" maskUnits="userSpaceOnUse" x="0" y="0" width="48" height="48"><rect width="48" height="48" fill="#fff"/><g class="chatballs-chat-eyes"><rect x="15" y="16.4" width="4" height="7.2" rx="2" fill="#000"/><rect x="29" y="16.4" width="4" height="7.2" rx="2" fill="#000"/></g><path d="M17.6 26.4c1.7 2.4 3.9 3.6 6.4 3.6s4.7-1.2 6.4-3.6" fill="none" stroke="#000" stroke-width="2.6" stroke-linecap="round"/></mask><circle cx="24" cy="3.6" r="2.4"/><rect x="22.8" y="4.8" width="2.4" height="5"/><rect x="2.2" y="18" width="2.6" height="8.4" rx="1.3"/><rect x="43.2" y="18" width="2.6" height="8.4" rx="1.3"/><path mask="url(#chatballs-launcher-face)" d="M14 9.2h20a6.8 6.8 0 0 1 6.8 6.8v12.4a6.8 6.8 0 0 1-6.8 6.8H21.6l-6.4 5.2a.9.9 0 0 1-1.5-.7v-4.5h-.3A6.8 6.8 0 0 1 7.2 28.4V16A6.8 6.8 0 0 1 14 9.2Z"/></svg>';
var CHEVRON_ICON = '<svg viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="#fff" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" style="flex:none"><polyline points="6 9 12 15 18 9"/></svg>';
var btn = document.createElement("button");
btn.className = "chatballs-chat-launcher";
btn.type = "button";
btn.setAttribute("aria-label", "Открыть чат");
btn.style.cssText = "position:fixed;right:24px;bottom:24px;height:52px;padding:0 20px;border:none;border-radius:999px;background:#1677ff;box-shadow:0 8px 24px rgba(22,119,255,0.35);cursor:pointer;z-index:2147483000;display:flex;align-items:center;gap:9px;color:#fff;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica Neue',Arial,sans-serif;font-size:15px;font-weight:600;line-height:1;letter-spacing:0;transition:transform .18s ease,box-shadow .18s ease;";
btn.innerHTML = '<svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="#fff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="flex:none"><path d="M17.802 17.292s.077-.055.2-.149c1.843-1.425 3-3.49 3-5.789c0-4.286-4.03-7.764-9-7.764c-4.97 0-9 3.478-9 7.764c0 4.288 4.03 7.646 9 7.646c.424 0 1.12-.028 2.088-.084c1.262.82 3.104 1.493 4.716 1.493c.499 0 .734-.41.414-.828c-.486-.596-1.156-1.551-1.416-2.29z"/><path d="M7.5 13.5c2.5 2.5 6.5 2.5 9 0"/></svg><span style="white-space:nowrap">Чат</span>';
btn.style.cssText = "position:fixed;right:24px;bottom:24px;width:56px;height:56px;padding:0;border:none;border-radius:50%;background:#1677ff;box-shadow:0 8px 24px rgba(22,119,255,0.35);cursor:pointer;z-index:2147483002;display:flex;align-items:center;justify-content:center;color:#fff;transition:transform .18s ease,box-shadow .18s ease,opacity .14s ease;";
btn.innerHTML = BOT_ICON;
var dot = document.createElement("span");
dot.style.cssText = "position:absolute;top:-2px;right:-2px;width:12px;height:12px;border-radius:50%;background:#faad14;border:2px solid #fff;display:none;";
@@ -78,20 +83,24 @@ LOADER_JS = r"""
function ensureFrame() {
if (frame) return;
shell = document.createElement("div");
shell.style.cssText = "position:fixed;right:24px;bottom:92px;width:" + PANEL_WIDTH.normal + ";height:" + PANEL_HEIGHT.normal + ";z-index:2147483001;display:none;transition:width .32s cubic-bezier(.4,0,.2,1),height .32s cubic-bezier(.4,0,.2,1);";
frame = document.createElement("iframe");
frame.src = panelUrl;
frame.title = "Чат";
frame.style.cssText = "position:fixed;right:24px;bottom:24px;width:min(440px,calc(100vw - 32px));height:min(720px,calc(100vh - 48px));border:none;border-radius:16px;box-shadow:0 12px 40px rgba(0,0,0,0.18);z-index:2147483000;display:none;background:transparent;";
frame.style.cssText = "width:100%;height:100%;border:none;border-radius:24px;box-shadow:0 12px 40px rgba(0,0,0,0.18);background:transparent;display:block;transform-origin:0 0;";
shell.appendChild(frame);
// Панель может открыться раньше, чем загрузится iframe — тогда сообщение
// «открыто» (по нему виджет доскроллит ленту вниз) будет потеряно, поэтому
// повторяем его на load.
frame.addEventListener("load", function () { if (open) notifyOpened(); });
document.body.appendChild(frame);
document.body.appendChild(shell);
window.addEventListener("message", function (e) {
if (e.origin !== origin || !frame || e.source !== frame.contentWindow) return;
var d = e.data || {};
if (d.instanceId && d.instanceId !== instanceId) return;
if (d.type === "chatballs-chat-close") setOpen(false);
if (d.type === "chatballs-chat-expand") setExpanded(Boolean(d.expanded));
if (d.type === "chatballs-chat-unread") {
unread = Boolean(d.unread);
updateDot();
@@ -107,15 +116,196 @@ LOADER_JS = r"""
});
}
// Развёрнутая панель почти вдвое шире и немного выше; переход анимируется
// самим iframe (transition в его стилях).
var PANEL_WIDTH = { normal: "min(440px,calc(100vw - 32px))", expanded: "min(820px,calc(100vw - 32px))" };
var PANEL_HEIGHT = { normal: "min(720px,calc(100vh - 116px))", expanded: "min(820px,calc(100vh - 116px))" };
function setExpanded(next) {
expanded = next;
if (!shell) return;
shell.style.width = next ? PANEL_WIDTH.expanded : PANEL_WIDTH.normal;
shell.style.height = next ? PANEL_HEIGHT.expanded : PANEL_HEIGHT.normal;
}
// Эффект джина: окно втягивается в кнопку, вытягивая горловину, — как в
// доке macOS. Аффинными трансформациями такое не получить: там изгиб, а не
// масштаб. Поэтому силуэт рисуется контуром SVG и перестраивается на каждом
// кадре. Сам iframe в анимации не участвует — менять его размеры покадрово
// нельзя, внутри переворачивалась бы вёрстка.
var GENIE_OPEN_MS = 300;
var GENIE_CLOSE_MS = 240;
var SVG_NS = "http://www.w3.org/2000/svg";
var genieSvg = null, geniePath = null, genieRaf = 0;
function ensureGenie() {
if (genieSvg) return;
genieSvg = document.createElementNS(SVG_NS, "svg");
genieSvg.style.cssText = "position:fixed;left:0;top:0;width:100%;height:100%;z-index:2147482999;pointer-events:none;display:none;overflow:visible;";
geniePath = document.createElementNS(SVG_NS, "path");
geniePath.style.filter = "drop-shadow(0 12px 40px rgba(0,0,0,0.18))";
genieSvg.appendChild(geniePath);
document.body.appendChild(genieSvg);
}
function smooth(x) {
if (x <= 0) return 0;
if (x >= 1) return 1;
return x * x * (3 - 2 * x);
}
function mix(a, b, k) { return a + (b - a) * k; }
function shapeAt(p, panel, icon) {
// Низ втягивается раньше верха — из-за этого и появляется горловина.
var pb = smooth(Math.min(1, p * 1.7));
var pt = smooth(Math.max(0, (p - 0.3) / 0.7));
var topY = mix(panel.top, icon.top, pt);
var topHalf = mix(panel.half, icon.half, pt);
var topCx = mix(panel.cx, icon.cx, pt);
var topR = Math.min(mix(panel.radius, icon.half, pt), topHalf);
var bottomY = mix(panel.bottom, icon.bottom, pb);
var bottomHalf = mix(panel.half, icon.half, pb);
var bottomCx = mix(panel.cx, icon.cx, pb);
var bottomR = Math.min(mix(panel.radius, icon.half, pb), bottomHalf);
return {
topY: topY, topL: topCx - topHalf, topRt: topCx + topHalf, topR: topR,
bottomY: bottomY, bottomL: bottomCx - bottomHalf, bottomRt: bottomCx + bottomHalf, bottomR: bottomR,
// Плечо кривой: чем длиннее горловина, тем мягче изгиб.
k: Math.max(0, (bottomY - topY) * 0.45),
fill: pt
};
}
function pathOf(g, dx, dy) {
var tl = g.topL - dx, tr = g.topRt - dx, ty = g.topY - dy;
var bl = g.bottomL - dx, br = g.bottomRt - dx, by = g.bottomY - dy;
return "M" + (tl + g.topR) + "," + ty +
"H" + (tr - g.topR) +
"Q" + tr + "," + ty + " " + tr + "," + (ty + g.topR) +
"C" + tr + "," + (ty + g.k) + " " + br + "," + (by - g.k) + " " + br + "," + (by - g.bottomR) +
"Q" + br + "," + by + " " + (br - g.bottomR) + "," + by +
"H" + (bl + g.bottomR) +
"Q" + bl + "," + by + " " + bl + "," + (by - g.bottomR) +
"C" + bl + "," + (by - g.k) + " " + tl + "," + (ty + g.k) + " " + tl + "," + (ty + g.topR) +
"Q" + tl + "," + ty + " " + (tl + g.topR) + "," + ty + "Z";
}
function paintGenie(p, panel, icon) {
var g = shapeAt(p, panel, icon);
geniePath.setAttribute("d", pathOf(g, 0, 0));
geniePath.setAttribute(
"fill",
"rgb(" + Math.round(mix(255, 22, g.fill)) + "," +
Math.round(mix(255, 119, g.fill)) + "," +
Math.round(mix(255, 255, g.fill)) + ")"
);
// Контур режет контейнер. Его собственная геометрия не меняется, поэтому
// координаты контура честно переводятся в его систему простым сдвигом.
var box = shell.getBoundingClientRect();
shell.style.clipPath = "path('" + pathOf(g, box.left, box.top) + "')";
// Окно внутри повторяет форму: ширина идёт за горловиной, высота — за
// длиной силуэта, наклон — за уходом центра к кнопке. Содержимое из-за
// этого сжимается и заваливается вместе с окном, а не стоит на месте.
var width = Math.max(1, box.width);
var height = Math.max(1, box.height);
var sx = Math.max(0.001, (g.topRt - g.topL) / width);
var sy = Math.max(0.001, (g.bottomY - g.topY) / height);
var lean = ((g.bottomL + g.bottomRt) / 2 - (g.topL + g.topRt) / 2) /
Math.max(1, g.bottomY - g.topY);
var skew = Math.atan((sy / sx) * lean) * 180 / Math.PI;
frame.style.transform = "translate(" + (g.topL - box.left) + "px," + (g.topY - box.top) + "px)" +
" scale(" + sx + "," + sy + ") skewX(" + skew + "deg)";
frame.style.borderRadius = Math.round(mix(24, icon.half, g.fill)) + "px";
}
function restPanel() {
shell.style.clipPath = "none";
frame.style.transform = "none";
frame.style.borderRadius = "24px";
}
function playGenie(from, to, duration, panel, icon, done) {
ensureGenie();
genieSvg.style.display = "block";
var started = 0;
window.cancelAnimationFrame(genieRaf);
function step(now) {
if (!started) started = now;
var linear = Math.min(1, (now - started) / duration);
// Мягкий вход и выход: резкий старт читается как рывок.
var eased = linear < 0.5 ? 2 * linear * linear : 1 - Math.pow(-2 * linear + 2, 2) / 2;
paintGenie(mix(from, to, eased), panel, icon);
if (linear < 1) genieRaf = window.requestAnimationFrame(step);
else done();
}
genieRaf = window.requestAnimationFrame(step);
}
function panelRect() {
var box = shell.getBoundingClientRect();
return {
top: box.top,
bottom: box.bottom,
cx: box.left + box.width / 2,
half: box.width / 2,
radius: 24
};
}
function iconRect() {
var box = btn.getBoundingClientRect();
return {
top: box.top,
bottom: box.bottom,
cx: box.left + box.width / 2,
half: box.width / 2
};
}
function notifyOpened() {
try { frame.contentWindow.postMessage({ type: "chatballs-chat-opened" }, origin); } catch (_) {}
}
function setOpen(next) {
ensureFrame();
// Слой силуэта нужен уже на первом кадре: первый же кадр рисуется до
// запуска анимации, чтобы панель не мелькнула целиком.
ensureGenie();
open = next;
frame.style.display = open ? "block" : "none";
btn.style.display = open ? "none" : "flex";
window.clearTimeout(contentTimer);
btn.style.opacity = "0";
btn.style.pointerEvents = "none";
if (open) {
// Контейнер раскладывается до расчёта: силуэт строится по настоящим
// границам панели, а они известны только разложенной.
shell.style.display = "block";
paintGenie(1, panelRect(), iconRect());
playGenie(1, 0, GENIE_OPEN_MS, panelRect(), iconRect(), function () {
if (!open) return;
genieSvg.style.display = "none";
restPanel();
btn.style.opacity = "1";
btn.style.pointerEvents = "";
});
} else {
var box = panelRect();
playGenie(0, 1, GENIE_CLOSE_MS, box, iconRect(), function () {
if (open) return;
genieSvg.style.display = "none";
shell.style.display = "none";
restPanel();
btn.style.opacity = "1";
btn.style.pointerEvents = "";
});
}
btn.innerHTML = open ? CHEVRON_ICON : BOT_ICON;
btn.setAttribute("aria-label", open ? "Свернуть чат" : "Открыть чат");
btn.appendChild(dot);
if (open) {
unread = false;
updateDot();
@@ -124,7 +314,7 @@ LOADER_JS = r"""
} else updateDot();
}
btn.addEventListener("click", function () { setOpen(true); });
btn.addEventListener("click", function () { setOpen(!open); });
function mount() {
document.body.appendChild(btn);
ensureFrame();
@@ -8,6 +8,7 @@ from django.conf import settings
from django.db import models, transaction
from django.utils import timezone
from chatballs.conversations.ai_turn import conversation_is_thinking
from chatballs.conversations.ingest import ingest_inbound
from chatballs.conversations.models import (
ConnectionIdentity,
@@ -346,12 +347,15 @@ def messages_payload(session: WebSession, since: int) -> dict:
"messages": [],
"call": None,
"reset": reset,
"thinking": False,
}
items = conversation.messages.filter(id__gt=since).order_by("created_at")
return {
"reset": reset,
"state": _STATE.get(conversation.control_mode, "ai"),
"lifecycle": conversation.lifecycle,
# Ответ уже считается: виджет показывает клиенту, что агент печатает.
"thinking": conversation_is_thinking(conversation.id),
"messages": [
{
"id": m.id,
@@ -197,15 +197,24 @@ CHATBALLS_OPENROUTER_BASE_URL = os.environ.get("CHATBALLS_OPENROUTER_BASE_URL",
CHATBALLS_AI_REQUEST_TIMEOUT = float(os.environ.get("CHATBALLS_AI_REQUEST_TIMEOUT", "30"))
CHATBALLS_AI_MAX_RETRIES = int(os.environ.get("CHATBALLS_AI_MAX_RETRIES", "2"))
CHATBALLS_AI_EMBEDDING_MODEL = os.environ.get("CHATBALLS_AI_EMBEDDING_MODEL", "openai/text-embedding-3-small")
# Ответ живому человеку ждут иначе, чем индексацию знаний: клиент в чате не
# станет ждать полторы минуты, пока провайдер доберёт три попытки по тридцать
# секунд. Ход диалога ограничен своим сроком (chatballs.ai.turn).
CHATBALLS_AI_TURN_TIMEOUT = float(os.environ.get("CHATBALLS_AI_TURN_TIMEOUT", "20"))
# Сколько ход имеет смысл: сообщение, пролежавшее в очереди дольше, отвечать
# уже поздно — клиент ушёл или его взял оператор, и диалог передаётся человеку.
CHATBALLS_AI_TURN_DEADLINE_SECONDS = int(
os.environ.get("CHATBALLS_AI_TURN_DEADLINE_SECONDS", "120")
)
# Модель расшифровки голосовых (OpenAI-совместимый /audio/transcriptions).
# Managed-провайдер CustoAI удалён (ADR-CHATBALLS-0042 §3): AI — только через
# интеграцию организации (BYOK).
# Long-poll hold-time мессенджеров (сек). Держим малым: единый воркер выполняет
# и inbound-поллинг, и outbox-диспатч в одном потоке — при большом hold-time
# getUpdates/updates блокирует цикл и outbox (приглашения звонков, уведомления,
# ответы AI) уходит с задержкой в размер long-poll на каждое подключение.
# Long-poll hold-time мессенджеров (сек). Держим малым: опрос идёт по
# подключениям последовательно в одном процессе, и hold-time каждого из них
# складывается в задержку приёма у остальных. Ответы AI от этого больше не
# зависят — их считает отдельная роль воркера (run_worker --role).
CHATBALLS_MESSENGER_POLL_TIMEOUT_SECONDS = int(os.environ.get("CHATBALLS_MESSENGER_POLL_TIMEOUT_SECONDS", "2"))
# Срок жизни анонимной сессии виджета: отсчёт от последней активности, а не от
@@ -61,6 +61,7 @@ export function ChatPage({
? <Hint id="chat-visibility">{t("admin.see_every_conversation_organization_operators")}</Hint>
: undefined}
viewerId={user.id}
sender={{ operatorName: user.fullName, company: user.organizationName }}
renderContextPanel={({ dialog, detail, applyConversation, startCall, closeContext, assignmentTimeoutMinutes }) => (
<ChatContextPanel
rightTab={rightTab}
@@ -1,24 +1,25 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { useEffect, useRef, useState } from "react";
import { Icon, LogoSpinner } from "../../shared/icons";
import { ComposerTemplatesMenu } from "./ComposerTemplatesMenu";
import { EmojiPicker } from "./EmojiPicker";
import { useMediaQuery } from "../../shared/useMediaQuery";
import { fetchReplyTemplates, sendFileMessage, sendOperatorMessage, sendVoiceMessage, type ReplyTemplateRef } from "./model";
import { sendFileMessage, sendOperatorMessage, sendVoiceMessage } from "./model";
import { formatSize } from "../ai/knowledge/model";
import { formatDuration } from "./VoiceMessage";
import { TEMPLATE_VARIABLE_LABEL, unfilledVariables, type TemplateValues } from "./templateVariables";
import { useComposerTemplates } from "./useComposerTemplates";
import { useVoiceRecorder } from "./useVoiceRecorder";
import type { ChannelKey, ControlMode } from "./types";
import { t } from "../../i18n";
const MAX_FILE_BYTES = 20 * 1024 * 1024;
export function Composer({ mode, loaded, assignedOperatorName, conversationId, channel, voiceAllowed = true, onClaim, onRelease, onReturnQueue, onClose, onSent }: { mode: ControlMode; loaded: boolean; assignedOperatorName?: string; conversationId: number | null; channel?: ChannelKey; voiceAllowed?: boolean; onClaim: () => void; onRelease: () => void; onReturnQueue: () => void; onClose: () => void; onSent: () => void }) {
export function Composer({ mode, loaded, assignedOperatorName, conversationId, channel, voiceAllowed = true, templateValues = {}, onClaim, onRelease, onReturnQueue, onClose, onSent }: { mode: ControlMode; loaded: boolean; assignedOperatorName?: string; conversationId: number | null; channel?: ChannelKey; voiceAllowed?: boolean; templateValues?: TemplateValues; onClaim: () => void; onRelease: () => void; onReturnQueue: () => void; onClose: () => void; onSent: () => void }) {
const [text, setText] = useState("");
const compact = useMediaQuery("(max-width: 768px)");
const [sending, setSending] = useState(false);
const [sendError, setSendError] = useState("");
const [templates, setTemplates] = useState<ReplyTemplateRef[]>([]);
const [templatesOpen, setTemplatesOpen] = useState(false);
const [attachment, setAttachment] = useState<File | null>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
@@ -35,25 +36,10 @@ export function Composer({ mode, loaded, assignedOperatorName, conversationId, c
// Сброс черновика вложения при смене диалога.
useEffect(() => { setAttachment(null); setSendError(""); }, [conversationId]);
useEffect(() => {
fetchReplyTemplates().then(setTemplates).catch(() => setTemplates([]));
}, []);
// Шаблоны «/» (дизайн-базлайн v2 §9): ввод «/» в начале открывает список,
// продолжение ввода фильтрует по названию.
const slashQuery = text.startsWith("/") ? text.slice(1).trim().toLowerCase() : null;
const visibleTemplates = useMemo(() => {
if (templates.length === 0) return [];
if (slashQuery === null) return templates;
return templates.filter((template) => template.title.toLowerCase().includes(slashQuery));
}, [templates, slashQuery]);
const menuOpen = templatesOpen || (slashQuery !== null && visibleTemplates.length > 0);
function applyTemplate(template: ReplyTemplateRef) {
setText(template.text);
setTemplatesOpen(false);
textareaRef.current?.focus();
}
const templates = useComposerTemplates({ text, setText, values: templateValues, textareaRef });
// Переменная шаблона без значения (у гостя нет имени) остаётся в тексте:
// пока оператор её не заполнит, ответ не уходит.
const unfilled = unfilledVariables(text);
// Запись голосового: во всех каналах (TG/MAX sendVoice, почта — вложением, Web — поллингом).
const recorder = useVoiceRecorder({
@@ -111,7 +97,7 @@ export function Composer({ mode, loaded, assignedOperatorName, conversationId, c
async function send() {
const value = text.trim();
if ((!value && !attachment) || conversationId == null || sending) return;
if ((!value && !attachment) || conversationId == null || sending || unfilled.length > 0) return;
setSending(true);
setSendError("");
try {
@@ -163,7 +149,9 @@ export function Composer({ mode, loaded, assignedOperatorName, conversationId, c
// Кадры A–C: композер активен всегда (решение 2) — первое сообщение
// перехватывает диалог; над полем одна строка-предупреждение.
const warning = mode === "ai"
const warning = unfilled.length > 0
? { color: "var(--warning-text)", dot: "var(--warning)", text: t("conversations.fill_template_variables", { names: unfilled.map((name) => t(TEMPLATE_VARIABLE_LABEL[name]).toLocaleLowerCase()).join(", ") }) }
: mode === "ai"
? { color: "var(--ai)", text: t("conversations.ai_handling_conversation_message_takes") }
: mode === "waiting"
? { color: "var(--warning-text)", dot: "var(--warning)", text: t("conversations.customer_waiting_message_assigns_conversation") }
@@ -174,17 +162,7 @@ export function Composer({ mode, loaded, assignedOperatorName, conversationId, c
<div className="composer-wrap">
{warning && <div className="composer-warning" style={{ color: warning.color }}><i style={{ background: warning.dot ?? warning.color }} />{warning.text}</div>}
<div className="composer-box">
{menuOpen && (
<div className="composer-templates-menu">
{visibleTemplates.length === 0 && <p>{t("conversations.no_matching_templates")}</p>}
{visibleTemplates.map((template) => (
<button key={template.id} type="button" onMouseDown={(event) => { event.preventDefault(); applyTemplate(template); }}>
<strong>{template.title}</strong>
<small>{template.text.replace(/\s+/g, " ").slice(0, 80)}</small>
</button>
))}
</div>
)}
{templates.menuOpen && <ComposerTemplatesMenu items={templates.visible} onPick={templates.apply} />}
{attachment && (
<div className="composer-attachment">
<Icon name="paperclip" size={14} />
@@ -202,14 +180,14 @@ export function Composer({ mode, loaded, assignedOperatorName, conversationId, c
value={text}
onChange={(event) => setText(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Escape" && menuOpen) { setTemplatesOpen(false); if (slashQuery !== null) setText(""); return; }
if (event.key === "Escape" && templates.menuOpen) { templates.close(); if (templates.slashQuery !== null) setText(""); return; }
if (event.key === "Enter" && !event.shiftKey) {
if (slashQuery !== null && visibleTemplates.length > 0) { event.preventDefault(); applyTemplate(visibleTemplates[0]); return; }
if (templates.slashQuery !== null && templates.visible.length > 0) { event.preventDefault(); templates.apply(templates.visible[0]); return; }
event.preventDefault();
void send();
}
}}
onBlur={() => setTemplatesOpen(false)}
onBlur={templates.close}
onPaste={(event) => {
const file = Array.from(event.clipboardData?.files ?? [])[0];
if (file) { event.preventDefault(); pickFile(file); }
@@ -226,12 +204,12 @@ export function Composer({ mode, loaded, assignedOperatorName, conversationId, c
<Icon name="mic" size={17} />
</button>
)}
{templates.length > 0 && (
<button className="composer-tool is-labeled" title={t("conversations.reply_templates")} type="button" onClick={() => setTemplatesOpen((open) => !open)}>
{templates.available && (
<button className="composer-tool is-labeled" title={t("conversations.reply_templates")} type="button" onClick={templates.toggle}>
<Icon name="text" size={16} />{t("conversations.templates")}</button>
)}
<span className="composer-spacer" />
<button className="composer-send" type="button" onClick={() => void send()} disabled={sending || (!text.trim() && !attachment)}><span>{t("conversations.send")}</span><kbd>⏎</kbd><Icon name="send" size={17} /></button>
<button className="composer-send" type="button" onClick={() => void send()} disabled={sending || (!text.trim() && !attachment) || unfilled.length > 0}><span>{t("conversations.send")}</span><kbd>⏎</kbd><Icon name="send" size={17} /></button>
</div>
</div>
</div>
@@ -0,0 +1,16 @@
import type { ReplyTemplateRef } from "./model";
import { t } from "../../i18n";
export function ComposerTemplatesMenu({ items, onPick }: { items: ReplyTemplateRef[]; onPick: (template: ReplyTemplateRef) => void }) {
return (
<div className="composer-templates-menu">
{items.length === 0 && <p>{t("conversations.no_matching_templates")}</p>}
{items.map((template) => (
<button key={template.id} type="button" onMouseDown={(event) => { event.preventDefault(); onPick(template); }}>
<strong>{template.title}</strong>
<small>{template.text.replace(/\s+/g, " ").slice(0, 80)}</small>
</button>
))}
</div>
);
}
@@ -57,7 +57,7 @@ import { t } from "../../i18n";
// backend по группам (ADR-CHATBALLS-0043); страница параметризуется заголовком,
// placeholder поиска и правой панелью через render-prop. Список и история —
// серверные окна: ни то, ни другое целиком не запрашивается.
export function ConversationWorkspace({ isOwner = false, canDelete = 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, sender }: {
isOwner?: boolean;
/** Удалять диалоги могут владелец и администратор (то же проверяет сервер). */
canDelete?: boolean;
@@ -74,6 +74,8 @@ export function ConversationWorkspace({ isOwner = false, canDelete = false, view
setScope: (scope: DialogScope) => void;
counters: ConversationCounters | null;
showScopeSwitcher?: boolean;
/** Кто отвечает — для переменных шаблонов ответов. */
sender?: { operatorName: string; company: string };
}) {
const [listTab, setListTab] = useState<ListTab>("all");
const [sort, setSort] = useState<ListSort>("activity");
@@ -289,6 +291,11 @@ export function ConversationWorkspace({ isOwner = false, canDelete = false, view
mode={controlMode}
channel={selectedDialog?.channel}
voiceAllowed={detail?.connection?.voiceMessages ?? false}
templateValues={{
client_name: detail?.contact?.isGuest ? "" : detail?.contact?.name,
operator_name: sender?.operatorName,
company: sender?.company,
}}
loaded={detailLoaded}
assignedOperatorName={detail?.assignedOperator?.name}
conversationId={selectedId}
@@ -72,7 +72,8 @@ export type ApiConversation = {
connection: { id: number; provider: "EMAIL" | "MAX" | "TELEGRAM" | "VK" | "WEB"; name: string; voiceMessages?: boolean; audioCalls?: boolean; videoCalls?: boolean } | null;
// Контакт — единственный источник identity диалога.
// phone появляется после явного шаринга контакта; username (@логин TG/MAX) — только в detail-режиме.
contact: { id: number; name: string; email?: string; phone?: string; username?: string; avatarUrl?: string; description?: string; company?: string; city?: string } | null;
// isGuest — только в detail-режиме: имя гостя виджета — подпись «Гость · код», а не имя.
contact: { id: number; name: string; email?: string; phone?: string; username?: string; avatarUrl?: string; description?: string; company?: string; city?: string; isGuest?: boolean } | null;
lifecycle: "OPEN" | "CLOSED" | "SPAM";
controlMode: "AI" | "HUMAN" | "PAUSED";
expectedResponder: string;
@@ -371,6 +372,18 @@ export const createConversationLabel = (name: string, color = "") =>
}).then((r) => r.label);
export const fetchReplyTemplates = () =>
api<{ items: ReplyTemplateRef[] }>("/api/v1/conversations/templates/").then((r) => r.items);
export const createReplyTemplate = (title: string, text: string) =>
api<{ template: ReplyTemplateRef }>("/api/v1/conversations/templates/", {
method: "POST",
body: JSON.stringify({ title, text }),
}).then((r) => r.template);
export const updateReplyTemplate = (id: number, title: string, text: string) =>
api<{ template: ReplyTemplateRef }>(`/api/v1/conversations/templates/${id}/`, {
method: "PATCH",
body: JSON.stringify({ title, text }),
}).then((r) => r.template);
export const deleteReplyTemplate = (id: number) =>
api<void>(`/api/v1/conversations/templates/${id}/`, { method: "DELETE" });
export const transcribeMessage = (messageId: number) =>
api<{ message: ApiMessage }>(`/api/v1/conversations/messages/${messageId}/transcribe/`, { method: "POST" }).then((r) => r.message);
@@ -0,0 +1,24 @@
import { describe, expect, it } from "vitest";
import { renderTemplate, unfilledVariables } from "./templateVariables";
describe("переменные шаблона ответа", () => {
it("подставляет известные значения", () => {
const text = renderTemplate("Здравствуйте, {{client_name}}! Меня зовут {{ operator_name }}, {{company}}.", {
client_name: "Дмитрий",
operator_name: "Анна",
company: "Ателье Норд",
});
expect(text).toBe("Здравствуйте, Дмитрий! Меня зовут Анна, Ателье Норд.");
});
it("оставляет переменную без значения и чужие скобки как есть", () => {
const text = renderTemplate("Здравствуйте, {{client_name}}! {{unknown}}", { client_name: " ", company: "Ателье Норд" });
expect(text).toBe("Здравствуйте, {{client_name}}! {{unknown}}");
});
it("находит незаполненные переменные без повторов", () => {
expect(unfilledVariables("{{client_name}}, {{client_name}} и {{company}}, {{unknown}}")).toEqual(["client_name", "company"]);
expect(unfilledVariables("Готовый текст")).toEqual([]);
});
});
@@ -0,0 +1,41 @@
import type { MessageKey } from "../../i18n/ru";
// Переменные шаблонов ответов. В тексте шаблона хранится код, а не слово на
// языке интерфейса: язык организации меняется, код — нет. Тот же список
// проверяет сервер при сохранении (conversations/chat_extras_views.py).
export const TEMPLATE_VARIABLES = ["client_name", "operator_name", "company"] as const;
export type TemplateVariable = (typeof TEMPLATE_VARIABLES)[number];
export type TemplateValues = Partial<Record<TemplateVariable, string>>;
export const TEMPLATE_VARIABLE_LABEL: Record<TemplateVariable, MessageKey> = {
client_name: "conversations.variable_client_name",
operator_name: "conversations.variable_operator_name",
company: "conversations.variable_company",
};
const TOKEN = /\{\{\s*(\w+)\s*\}\}/g;
export function variableToken(variable: TemplateVariable): string {
return `{{${variable}}}`;
}
function isVariable(name: string): name is TemplateVariable {
return (TEMPLATE_VARIABLES as readonly string[]).includes(name);
}
/** Подставляет известные значения. Переменная без значения (у гостя нет
* имени) остаётся в тексте как есть — её заполнит оператор. */
export function renderTemplate(text: string, values: TemplateValues): string {
return text.replace(TOKEN, (token, name: string) => {
const value = isVariable(name) ? values[name]?.trim() : "";
return value || token;
});
}
/** Переменные, которые остались в тексте незаполненными, без повторов. */
export function unfilledVariables(text: string): TemplateVariable[] {
const found = [...text.matchAll(TOKEN)].map((match) => match[1]).filter(isVariable);
return [...new Set(found)];
}
@@ -0,0 +1,45 @@
import { useEffect, useMemo, useState, type RefObject } from "react";
import { fetchReplyTemplates, type ReplyTemplateRef } from "./model";
import { renderTemplate, type TemplateValues } from "./templateVariables";
// Шаблоны «/» (дизайн-базлайн v2 §9): ввод «/» в начале открывает список,
// продолжение ввода фильтрует по названию. Выбранный шаблон заменяет текст
// поля, переменные подставляются сразу — оператор видит готовый ответ.
export function useComposerTemplates({ text, setText, values, textareaRef }: {
text: string;
setText: (text: string) => void;
values: TemplateValues;
textareaRef: RefObject<HTMLTextAreaElement | null>;
}) {
const [templates, setTemplates] = useState<ReplyTemplateRef[]>([]);
const [open, setOpen] = useState(false);
useEffect(() => {
fetchReplyTemplates().then(setTemplates).catch(() => setTemplates([]));
}, []);
const slashQuery = text.startsWith("/") ? text.slice(1).trim().toLowerCase() : null;
const visible = useMemo(() => {
if (templates.length === 0) return [];
if (slashQuery === null) return templates;
return templates.filter((template) => template.title.toLowerCase().includes(slashQuery));
}, [templates, slashQuery]);
function apply(template: ReplyTemplateRef) {
setText(renderTemplate(template.text, values));
setOpen(false);
textareaRef.current?.focus();
}
return {
available: templates.length > 0,
visible,
slashQuery,
menuOpen: open || (slashQuery !== null && visible.length > 0),
toggle: () => setOpen((current) => !current),
close: () => setOpen(false),
apply,
};
}
@@ -4,6 +4,7 @@ import { fetchHelpArticle, sendArticleFeedback } from "./api";
import { HelpChevronIcon, HelpThumbIcon } from "./HelpIcons";
import { HelpLayout } from "./HelpLayout";
import { HelpAttachments } from "./HelpAttachments";
import { HelpRelatedArticles } from "./HelpRelatedArticles";
import { MarkdownContent, parseMarkdown } from "./MarkdownContent";
import type { HelpArticle as HelpArticleType, HelpManifest } from "./types";
import { t } from "../../i18n";
@@ -63,7 +64,9 @@ export function HelpArticle({
return (
<HelpLayout
compactHeader
portal={manifest.portal}
manifest={manifest}
activeCategoryId={article?.category.id}
activeArticleSlug={article?.slug}
search={search}
onSearchChange={setSearch}
onSearchSubmit={searchSubmit}
@@ -128,6 +131,7 @@ export function HelpArticle({
</>
)}
</section>
<HelpRelatedArticles articles={article.relatedArticles ?? []} />
</article>
{headings.length > 0 && (
<aside className="help-toc" aria-label={t("portals.article_contents")}>
@@ -12,6 +12,8 @@ import type { HelpManifest } from "./types";
import "./styles-layout.css";
import "./styles-home.css";
import "./styles-article.css";
import "./styles-navigation.css";
import "./styles-related.css";
import "./styles-responsive.css";
import { t } from "../../i18n";
@@ -80,7 +80,8 @@ export function HelpHome({ manifest }: { manifest: HelpManifest }) {
return (
<HelpLayout
portal={manifest.portal}
manifest={manifest}
activeCategoryId={selectedCategory?.id}
search={search}
onSearchChange={setSearch}
>
@@ -2,26 +2,32 @@ import type { ReactNode } from "react";
import { LogoIcon } from "../../shared/icons";
import { HelpSearch } from "./HelpSearch";
import { HelpNavigation } from "./HelpNavigation";
import { PortalWebWidget } from "./PortalWebWidget";
import type { HelpPortal } from "./types";
import type { HelpManifest } from "./types";
import { t } from "../../i18n";
export function HelpLayout({
portal,
manifest,
children,
search,
onSearchChange,
onSearchSubmit,
compactHeader = false,
activeCategoryId = null,
activeArticleSlug = null,
}: {
portal: HelpPortal;
manifest: HelpManifest;
children: ReactNode;
search: string;
onSearchChange: (value: string) => void;
onSearchSubmit?: (value: string) => void;
compactHeader?: boolean;
activeCategoryId?: number | null;
activeArticleSlug?: string | null;
}) {
const homeHref = "/";
const portal = manifest.portal;
return (
<div className={`help-center ${compactHeader ? "has-compact-header" : ""}`}>
<header className="help-header">
@@ -40,7 +46,14 @@ export function HelpLayout({
/>
)}
</header>
<main>{children}</main>
<main className="help-page-grid">
<HelpNavigation
manifest={manifest}
activeCategoryId={activeCategoryId}
activeArticleSlug={activeArticleSlug}
/>
<div className="help-page-content">{children}</div>
</main>
<footer className="help-footer">
<div className="help-footer-row">
<a className="help-footer-brand" href={homeHref}>
@@ -0,0 +1,178 @@
import { useEffect, useMemo, useState } from "react";
import { fetchHelpArticles } from "./api";
import { HelpChevronIcon } from "./HelpIcons";
import type { HelpArticle, HelpCategory, HelpManifest } from "./types";
import { t } from "../../i18n";
function articleHref(slug: string): string {
return `/articles/${encodeURIComponent(slug)}/`;
}
type BranchProps = {
category: HelpCategory;
childrenByParent: Map<number | null, HelpCategory[]>;
activePath: Set<number>;
activeCategoryId: number | null;
activeArticleSlug: string | null;
locale: string;
};
function CategoryBranch({
category, childrenByParent, activePath, activeCategoryId, activeArticleSlug, locale,
}: BranchProps) {
const [expanded, setExpanded] = useState(activePath.has(category.id));
const [articles, setArticles] = useState<HelpArticle[] | null>(null);
const [hasMore, setHasMore] = useState(false);
const [loading, setLoading] = useState(false);
const [failed, setFailed] = useState(false);
const children = childrenByParent.get(category.id) ?? [];
useEffect(() => {
if (activePath.has(category.id)) setExpanded(true);
}, [activePath, category.id]);
useEffect(() => {
if (!expanded || articles !== null || category.articleCount === 0) return;
let cancelled = false;
setLoading(true);
fetchHelpArticles({ locale, category: category.slug, direct: true, limit: 50 })
.then((payload) => {
if (cancelled) return;
setArticles(payload.items);
setHasMore(payload.pagination.hasMore);
setFailed(false);
})
.catch(() => { if (!cancelled) setFailed(true); })
.finally(() => { if (!cancelled) setLoading(false); });
return () => { cancelled = true; };
}, [articles, category.articleCount, category.slug, expanded, locale]);
async function loadMore() {
if (loading || !articles) return;
setLoading(true);
try {
const payload = await fetchHelpArticles({
locale, category: category.slug, direct: true, limit: 50, offset: articles.length,
});
setArticles((current) => [...(current ?? []), ...payload.items]);
setHasMore(payload.pagination.hasMore);
setFailed(false);
} catch {
setFailed(true);
} finally {
setLoading(false);
}
}
const canExpand = category.articleCount > 0 || children.length > 0;
return (
<li className="help-nav-branch">
<div className="help-nav-row">
<a
aria-current={activeCategoryId === category.id && !activeArticleSlug ? "page" : undefined}
className={activeCategoryId === category.id ? "is-current" : ""}
href={`/?category=${encodeURIComponent(category.slug)}`}
>{category.name}</a>
{canExpand && (
<button
aria-label={t(expanded ? "portals.collapse_section" : "portals.expand_section", { name: category.name })}
aria-expanded={expanded}
className={expanded ? "is-expanded" : ""}
type="button"
onClick={() => setExpanded((value) => !value)}
><HelpChevronIcon /></button>
)}
</div>
{canExpand && expanded && (
<ul className="help-nav-children">
{articles?.map((article) => (
<li key={article.slug}>
<a
aria-current={activeArticleSlug === article.slug ? "page" : undefined}
className={`help-nav-article${activeArticleSlug === article.slug ? " is-current" : ""}`}
href={articleHref(article.slug)}
>{article.revision.title}</a>
</li>
))}
{children.map((child) => (
<CategoryBranch
key={child.id}
category={child}
childrenByParent={childrenByParent}
activePath={activePath}
activeCategoryId={activeCategoryId}
activeArticleSlug={activeArticleSlug}
locale={locale}
/>
))}
{loading && <li className="help-nav-status" aria-live="polite">…</li>}
{failed && <li className="help-nav-status">{t("portals.could_not_load_articles")}</li>}
{hasMore && !loading && (
<li><button className="help-nav-more" type="button" onClick={() => void loadMore()}>{t("portals.show_more")}</button></li>
)}
</ul>
)}
</li>
);
}
export function HelpNavigation({
manifest, activeCategoryId, activeArticleSlug,
}: {
manifest: HelpManifest;
activeCategoryId: number | null;
activeArticleSlug: string | null;
}) {
const [mobileOpen, setMobileOpen] = useState(false);
const childrenByParent = useMemo(() => {
const result = new Map<number | null, HelpCategory[]>();
for (const category of manifest.categories) {
const siblings = result.get(category.parentId) ?? [];
siblings.push(category);
result.set(category.parentId, siblings);
}
for (const siblings of result.values()) {
siblings.sort((a, b) => a.sortOrder - b.sortOrder || a.name.localeCompare(b.name));
}
return result;
}, [manifest.categories]);
const activePath = useMemo(() => {
const byId = new Map(manifest.categories.map((category) => [category.id, category]));
const path = new Set<number>();
let current = activeCategoryId ? byId.get(activeCategoryId) : undefined;
while (current && !path.has(current.id)) {
path.add(current.id);
current = current.parentId ? byId.get(current.parentId) : undefined;
}
return path;
}, [activeCategoryId, manifest.categories]);
return (
<aside className="help-navigation">
<button
aria-controls="help-navigation-tree"
aria-expanded={mobileOpen}
className="help-navigation-mobile-toggle"
type="button"
onClick={() => setMobileOpen((value) => !value)}
>{t("portals.all_sections")}<HelpChevronIcon /></button>
<nav aria-label={t("portals.navigation")} className={mobileOpen ? "is-open" : ""} id="help-navigation-tree">
<a className="help-nav-all" href="/">{t("portals.all_sections")}</a>
<ul>
{(childrenByParent.get(null) ?? []).map((category) => (
<CategoryBranch
key={category.id}
category={category}
childrenByParent={childrenByParent}
activePath={activePath}
activeCategoryId={activeCategoryId}
activeArticleSlug={activeArticleSlug}
locale={manifest.portal.defaultLocale}
/>
))}
</ul>
</nav>
</aside>
);
}
@@ -0,0 +1,22 @@
import { HelpArrowIcon } from "./HelpIcons";
import type { HelpArticle } from "./types";
import { t } from "../../i18n";
export function HelpRelatedArticles({ articles }: { articles: HelpArticle[] }) {
if (articles.length === 0) return null;
return (
<section className="help-related" aria-labelledby="help-related-title">
<h2 id="help-related-title">{t("portals.related_articles")}</h2>
<ul>
{articles.map((article) => (
<li key={article.slug}>
<a href={`/articles/${encodeURIComponent(article.slug)}/`}>
<span>{article.revision.title}</span>
<HelpArrowIcon />
</a>
</li>
))}
</ul>
</section>
);
}
@@ -6,13 +6,15 @@ export function fetchHelpManifest(): Promise<HelpManifest> {
}
export function fetchHelpArticles(
options: { locale?: string; category?: string; query?: string; offset?: number } = {},
options: { locale?: string; category?: string; query?: string; offset?: number; limit?: number; direct?: boolean } = {},
): Promise<{ items: HelpArticle[]; pagination: { hasMore: boolean; limit: number; offset: number; total: number } }> {
const search = new URLSearchParams();
if (options.locale) search.set("locale", options.locale);
if (options.category) search.set("category", options.category);
if (options.query) search.set("q", options.query);
if (options.offset) search.set("offset", String(options.offset));
if (options.limit) search.set("limit", String(options.limit));
if (options.direct) search.set("direct", "1");
const suffix = search.size ? `?${search.toString()}` : "";
return api(`/api/v1/help/articles/${suffix}`);
}
@@ -26,7 +26,7 @@ html.help-center-document body {
}
.help-header {
width: min(1024px, calc(100% - 48px));
width: min(1280px, calc(100% - 48px));
margin: 0 auto;
padding: 34px 0 0;
}
@@ -154,7 +154,7 @@ html.help-center-document body {
.help-footer {
display: grid;
width: min(1024px, calc(100% - 48px));
width: min(1280px, calc(100% - 48px));
margin: 80px auto 0;
padding: 34px 0 42px;
gap: 18px;
@@ -293,4 +293,3 @@ html.help-center-document body {
.help-fatal span {
color: var(--help-text-muted);
}
@@ -0,0 +1,201 @@
.help-page-grid {
display: grid;
width: min(1280px, calc(100% - 48px));
margin: 0 auto;
grid-template-columns: 240px minmax(0, 1fr);
gap: 44px;
align-items: start;
}
.help-page-content {
min-width: 0;
}
.help-page-content .help-hero,
.help-page-content .help-home-content,
.help-page-content .help-article-shell {
width: 100%;
}
.help-page-content .help-article-grid {
grid-template-columns: minmax(0, 1fr) minmax(160px, 190px);
gap: 32px;
}
.help-navigation {
position: sticky;
top: 24px;
max-height: calc(100vh - 48px);
margin-top: 42px;
padding-right: 18px;
overflow: auto;
border-right: 1px solid var(--help-line-soft);
scrollbar-width: thin;
}
.help-navigation-mobile-toggle {
display: none;
}
.help-navigation nav > ul,
.help-nav-children {
margin: 0;
padding: 0;
list-style: none;
}
.help-nav-all,
.help-nav-row > a,
.help-nav-article,
.help-nav-more {
display: block;
padding: 9px 10px;
border-radius: var(--help-radius-sm);
color: var(--help-text);
font-size: 14px;
line-height: 1.35;
text-decoration: none;
}
.help-nav-all {
margin-bottom: 8px;
font-weight: var(--help-heading-weight);
}
.help-nav-row {
display: flex;
min-width: 0;
align-items: flex-start;
}
.help-nav-row > a {
min-width: 0;
flex: 1;
color: var(--help-ink);
font-weight: var(--help-heading-weight);
}
.help-nav-row > button {
display: grid;
width: 32px;
height: 36px;
flex: none;
place-items: center;
border: 0;
border-radius: var(--help-radius-sm);
background: transparent;
color: var(--help-text-muted);
cursor: pointer;
}
.help-nav-row > button svg {
transition: transform 150ms ease;
}
.help-nav-row > button.is-expanded svg {
transform: rotate(90deg);
}
.help-nav-children {
margin: 1px 0 7px 15px;
padding-left: 9px;
border-left: 1px solid var(--help-line-soft);
}
.help-nav-article {
color: var(--help-text-muted);
font-size: 13px;
}
.help-nav-all:hover,
.help-nav-row > a:hover,
.help-nav-article:hover,
.help-nav-more:hover,
.help-nav-row > button:hover {
background: var(--help-surface-soft);
color: var(--help-ink);
}
.help-nav-row > a.is-current,
.help-nav-article.is-current {
background: var(--help-surface-soft);
color: var(--help-accent);
font-weight: var(--help-heading-weight);
}
.help-nav-more {
width: 100%;
border: 0;
background: transparent;
text-align: left;
cursor: pointer;
}
.help-nav-status {
padding: 9px 10px;
color: var(--help-text-subtle);
font-size: 13px;
}
@media (max-width: 1100px) {
.help-page-content .help-article-grid {
display: block;
}
.help-page-content .help-toc {
display: none;
}
}
@media (max-width: 860px) {
.help-page-grid {
display: block;
width: min(100% - 36px, 680px);
}
.help-navigation {
position: static;
max-height: none;
margin-top: 20px;
padding: 0;
overflow: visible;
border-right: 0;
border-bottom: 1px solid var(--help-line-soft);
}
.help-navigation-mobile-toggle {
display: flex;
width: 100%;
min-height: 44px;
padding: 0 4px;
align-items: center;
justify-content: space-between;
border: 0;
background: transparent;
color: var(--help-ink);
font-family: var(--help-font-heading);
font-weight: var(--help-heading-weight);
cursor: pointer;
}
.help-navigation-mobile-toggle[aria-expanded="true"] svg {
transform: rotate(90deg);
}
.help-navigation nav {
display: none;
max-height: 60vh;
padding: 8px 0 14px;
overflow: auto;
}
.help-navigation nav.is-open {
display: block;
}
}
@media (max-width: 560px) {
.help-page-grid {
width: calc(100% - 28px);
}
}
@@ -0,0 +1,49 @@
.help-related {
margin-top: 46px;
}
.help-related h2 {
margin: 0 0 18px;
font-family: var(--help-font-heading);
font-size: 23px;
font-weight: var(--help-heading-weight);
letter-spacing: -0.03em;
}
.help-related ul {
margin: 0;
padding: 0;
border-top: 1px solid var(--help-line);
list-style: none;
}
.help-related li {
border-bottom: 1px solid var(--help-line-soft);
}
.help-related a {
display: flex;
min-height: 52px;
padding: 12px 2px;
align-items: center;
justify-content: space-between;
gap: 16px;
color: var(--help-ink);
font-size: 15px;
font-weight: 600;
line-height: 1.4;
text-decoration: none;
}
.help-related a svg {
flex: none;
color: var(--help-text-muted);
}
.help-related a:hover {
color: var(--help-accent);
}
.help-related a:hover svg {
color: var(--help-accent);
}
@@ -35,6 +35,7 @@ export type HelpArticle = {
updatedAt: string;
// Файлы статьи: картинки вставлены в текст, остальное посетитель скачивает.
attachments?: HelpAttachment[];
relatedArticles?: HelpArticle[];
};
export type HelpPortal = {
@@ -0,0 +1,66 @@
import { Modal } from "antd";
import { useRef, useState } from "react";
import { FormField, TextAreaField } from "../../shared/form-controls";
import { Button } from "../../shared/ui-controls";
import { createReplyTemplate, updateReplyTemplate, type ReplyTemplateRef } from "../conversations/model";
import { variableToken, type TemplateVariable } from "../conversations/templateVariables";
import { TemplateVariableMenu } from "./TemplateVariableMenu";
import { t } from "../../i18n";
// Окно шаблона ответа: название и текст. Та же форма, что у интеграций и
// переименования группы; ошибки (занятое название, пустой текст) — от сервера.
export function ReplyTemplateForm({ initial, onClose, onSaved }: {
initial: ReplyTemplateRef | null;
onClose: () => void;
onSaved: () => void;
}) {
const [title, setTitle] = useState(initial?.title ?? "");
const [text, setText] = useState(initial?.text ?? "");
const [saving, setSaving] = useState(false);
const [error, setError] = useState("");
const textRef = useRef<HTMLTextAreaElement>(null);
function insertVariable(variable: TemplateVariable) {
const token = variableToken(variable);
const area = textRef.current;
const start = area?.selectionStart ?? text.length;
const end = area?.selectionEnd ?? text.length;
setText(text.slice(0, start) + token + text.slice(end));
window.requestAnimationFrame(() => {
if (!area) return;
area.focus();
area.setSelectionRange(start + token.length, start + token.length);
});
}
async function save() {
setSaving(true);
setError("");
try {
if (initial) await updateReplyTemplate(initial.id, title.trim(), text.trim());
else await createReplyTemplate(title.trim(), text.trim());
onSaved();
} catch (caught) {
setError(caught instanceof Error ? caught.message : t("settings.could_not_save_template"));
} finally {
setSaving(false);
}
}
return (
<Modal open title={initial ? t("settings.edit_template") : t("settings.new_template")} onCancel={onClose} footer={null} destroyOnClose>
<div className="integration-form">
<FormField label={t("common.title")} value={title} onChange={setTitle} />
<TextAreaField label={t("settings.template_text")} value={text} onChange={setText} inputRef={textRef} />
<TemplateVariableMenu onPick={insertVariable} />
{error && <div className="integration-form-error">{error}</div>}
<div className="integration-form-actions">
<Button variant="secondary" onClick={onClose}>{t("common.cancel")}</Button>
<Button variant="primary" disabled={saving || !title.trim() || !text.trim()} onClick={() => void save()}>{t("common.save")}</Button>
</div>
</div>
</Modal>
);
}
@@ -0,0 +1,73 @@
import { Modal } from "antd";
import { useState } from "react";
import { EmptyState } from "../../shared/ui";
import { Icon } from "../../shared/icons";
import { Button } from "../../shared/ui-controls";
import { deleteReplyTemplate, type ReplyTemplateRef } from "../conversations/model";
import { fmt, t } from "../../i18n";
// Раздел «Шаблоны ответов» (согласован по образцу кадра N2 «Группы», своего
// кадра в базлайне нет): строка — название · начало текста · дата изменения ·
// изменить/удалить. Создание — primary-кнопкой в шапке раздела, правка —
// окном, как у интеграций. Без права на настройки список только читается.
export function ReplyTemplatesCard({ items, canManage, reload, onEdit }: {
items: ReplyTemplateRef[];
canManage: boolean;
reload: () => void;
onEdit: (template: ReplyTemplateRef) => void;
}) {
const [deleting, setDeleting] = useState<ReplyTemplateRef | null>(null);
const [deleteError, setDeleteError] = useState("");
async function remove() {
if (!deleting) return;
setDeleteError("");
try {
await deleteReplyTemplate(deleting.id);
setDeleting(null);
reload();
} catch (caught) {
setDeleteError(caught instanceof Error ? caught.message : t("settings.could_not_delete"));
}
}
if (items.length === 0) return <EmptyState title={t("settings.no_templates_yet")} />;
return (
<>
<div className="table-card settings-groups-card">
{items.map((template) => (
<div
className={`settings-group-row settings-template-row ${canManage ? "is-clickable" : ""}`}
key={template.id}
onClick={canManage ? () => onEdit(template) : undefined}
>
<strong>{template.title}</strong>
<span className="settings-template-preview">{template.text.replace(/\s+/g, " ")}</span>
<small>{fmt.shortDate(template.updatedAt)}</small>
{canManage && (
<div className="settings-group-actions" onClick={(event) => event.stopPropagation()}>
<button aria-label={t("common.edit")} title={t("common.edit")} type="button" onClick={() => onEdit(template)}><Icon name="edit" size={14} /></button>
<button aria-label={t("common.delete")} title={t("common.delete")} className="is-danger" type="button" onClick={() => { setDeleteError(""); setDeleting(template); }}><Icon name="trash" size={14} /></button>
</div>
)}
</div>
))}
</div>
{deleting && (
<Modal open title={t("settings.delete_template")} onCancel={() => setDeleting(null)} footer={null} destroyOnClose>
<div className="integration-form">
<p>{t("settings.template_will_be_deleted", { name: deleting.title })}</p>
{deleteError && <div className="integration-form-error">{deleteError}</div>}
<div className="integration-form-actions">
<Button variant="secondary" onClick={() => setDeleting(null)}>{t("common.cancel")}</Button>
<Button variant="danger-outline" onClick={() => void remove()}>{t("common.delete")}</Button>
</div>
</div>
</Modal>
)}
</>
);
}
@@ -18,8 +18,12 @@ import { PlatformSettingsCard } from "./PlatformSettingsCard";
import { StorageSettingsCard } from "./StorageSettingsCard";
import { GroupsSettingsCard } from "./GroupsSettingsCard";
import { QueuePolicyCard } from "./QueuePolicyCard";
import { ReplyTemplateForm } from "./ReplyTemplateForm";
import { ReplyTemplatesCard } from "./ReplyTemplatesCard";
import { DEFAULT_SETTINGS_SECTION, visibleSettingsSections, type SettingsSectionKey } from "./sections";
import { useIntegrations } from "./useIntegrations";
import { useReplyTemplates } from "./useReplyTemplates";
import type { ReplyTemplateRef } from "../conversations/model";
import { useOnboarding } from "../onboarding/useOnboarding";
import type { TourTarget } from "../onboarding/steps";
import { t } from "../../i18n";
@@ -50,6 +54,8 @@ export function SettingsPage({ user, onUserUpdated, reload, groups = [], section
const manager = isManager(user);
const integrations = useIntegrations(manager);
const [form, setForm] = useState<{ kind: IntegrationKind; initial: Integration | null } | null>(null);
const templates = useReplyTemplates(manager);
const [templateForm, setTemplateForm] = useState<{ initial: ReplyTemplateRef | null } | null>(null);
// Кадр M: на узком экране субменю и раздел — два отдельных экрана.
const mobile = useMediaQuery("(max-width: 900px)");
@@ -60,6 +66,7 @@ export function SettingsPage({ user, onUserUpdated, reload, groups = [], section
const connectionsFailed = connections.some((item) => item.isActive && item.status === "ERROR");
const counts: Partial<Record<SettingsSectionKey, number>> = {
groups: groups.length,
templates: templates.items.length,
ai: providers.length,
integrations: connections.length,
};
@@ -121,7 +128,9 @@ export function SettingsPage({ user, onUserUpdated, reload, groups = [], section
? <Button variant="primary" icon="plus" onClick={() => setForm({ kind: "LLM_PROVIDER", initial: null })}>{t("settings.add_provider")}</Button>
: current?.key === "integrations"
? <Button variant="primary" icon="plus" onClick={() => setForm({ kind: "MESSENGER", initial: null })}>{t("settings.add_connection")}</Button>
: null;
: current?.key === "templates" && canManageSettings(user)
? <Button variant="primary" icon="plus" onClick={() => setTemplateForm({ initial: null })}>{t("settings.add_template")}</Button>
: null;
return (
<div className="settings-layout">
@@ -151,6 +160,8 @@ export function SettingsPage({ user, onUserUpdated, reload, groups = [], section
providers={providers}
connections={connections}
onEditIntegration={(item) => setForm({ kind: item.kind, initial: item })}
templates={templates}
onEditTemplate={(item) => setTemplateForm({ initial: item })}
/>
</div>
</section>
@@ -163,11 +174,18 @@ export function SettingsPage({ user, onUserUpdated, reload, groups = [], section
onSaved={() => { setForm(null); integrations.reload(); }}
/>
)}
{templateForm && (
<ReplyTemplateForm
initial={templateForm.initial}
onClose={() => setTemplateForm(null)}
onSaved={() => { setTemplateForm(null); templates.reload(); }}
/>
)}
</div>
);
}
function SectionBody({ section, user, onUserUpdated, reload, groups, integrations, providers, connections, onEditIntegration }: {
function SectionBody({ section, user, onUserUpdated, reload, groups, integrations, providers, connections, onEditIntegration, templates, onEditTemplate }: {
section: SettingsSectionKey;
user: SessionUser;
onUserUpdated: (user: SessionUser) => void;
@@ -177,6 +195,8 @@ function SectionBody({ section, user, onUserUpdated, reload, groups, integration
providers: Integration[];
connections: Integration[];
onEditIntegration: (integration: Integration) => void;
templates: ReturnType<typeof useReplyTemplates>;
onEditTemplate: (template: ReplyTemplateRef) => void;
}): ReactNode {
if (section === "organization") return <OrganizationSection user={user} onUserUpdated={onUserUpdated} />;
if (section === "groups") return <GroupsSettingsCard groups={groups} reload={reload} />;
@@ -185,6 +205,11 @@ function SectionBody({ section, user, onUserUpdated, reload, groups, integration
if (section === "platform") return <PlatformSettingsCard canManage={user.isInstanceAdmin} />;
if (section === "storage") return <StorageSettingsCard canManage={user.isInstanceAdmin} />;
if (section === "demo") return <DemoDataCard reload={reload} />;
if (section === "templates") {
if (templates.loading) return <LoadingState />;
if (templates.failed) return <EmptyState title={t("settings.could_not_load_templates")} />;
return <ReplyTemplatesCard items={templates.items} canManage={canManageSettings(user)} reload={templates.reload} onEdit={onEditTemplate} />;
}
if (integrations.loading) return <LoadingState />;
if (integrations.failed) return <EmptyState title={t("settings.could_not_load_integrations")} />;
return (
@@ -0,0 +1,23 @@
import { Dropdown } from "antd";
import { Icon } from "../../shared/icons";
import { TEMPLATE_VARIABLES, TEMPLATE_VARIABLE_LABEL, type TemplateVariable } from "../conversations/templateVariables";
import { t } from "../../i18n";
// «Вставить переменную» в окне шаблона ответа: коды переменных не нужно
// помнить — пункт меню вставляет его в позицию курсора.
export function TemplateVariableMenu({ onPick }: { onPick: (variable: TemplateVariable) => void }) {
const items = TEMPLATE_VARIABLES.map((variable) => ({
key: variable,
label: t(TEMPLATE_VARIABLE_LABEL[variable]),
onClick: () => onPick(variable),
}));
return (
<Dropdown menu={{ items }} trigger={["click"]} placement="bottomLeft" overlayClassName="app-dropdown">
<button className="link has-icon template-variable-trigger" type="button">
<Icon name="plus" size={13} strokeWidth={2.2} />{t("settings.insert_variable")}
</button>
</Dropdown>
);
}
@@ -10,6 +10,7 @@ export type SettingsSectionKey =
| "organization"
| "groups"
| "queue"
| "templates"
| "ai"
| "integrations"
| "communication"
@@ -51,6 +52,14 @@ export const SETTINGS_SECTIONS: SettingsSection[] = [
heading: t("settings.when_to_call_for_help"),
lead: t("settings.queue_section_lead"),
},
{
// Своего кадра в базлайне нет: раздел согласован по образцу «Групп» (N2).
key: "templates",
label: t("settings.reply_templates"),
icon: "text",
heading: t("settings.reply_templates"),
lead: t("settings.reply_templates_lead"),
},
{
key: "ai",
label: t("settings.ai_provider"),
@@ -386,6 +386,36 @@
margin: 10px 0 4px;
}
/* --- Шаблоны ответов: строка «Групп», вместо состава — начало текста --- */
.settings-template-row.is-clickable {
cursor: pointer;
}
.settings-template-row strong {
flex: 0 1 auto;
max-width: 40%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* «Вставить переменную» — ссылка под полем текста, не во всю ширину формы. */
.template-variable-trigger {
align-self: flex-start;
margin-top: -6px;
}
.settings-template-preview {
flex: 1;
min-width: 0;
overflow: hidden;
color: var(--n-4);
font-size: 12.5px;
text-overflow: ellipsis;
white-space: nowrap;
}
/* --- N3/N4 Таблицы: шапка 11px капсом на подложке, строки 62px --- */
.integrations-section {
@@ -0,0 +1,34 @@
import { useCallback, useEffect, useState } from "react";
import { fetchReplyTemplates, type ReplyTemplateRef } from "../conversations/model";
// Шаблоны грузятся на весь экран «Настроек», как интеграции: субменю
// показывает их число, а раздел — список.
export type ReplyTemplatesState = {
items: ReplyTemplateRef[];
loading: boolean;
failed: boolean;
reload: () => void;
};
export function useReplyTemplates(enabled: boolean): ReplyTemplatesState {
const [items, setItems] = useState<ReplyTemplateRef[]>([]);
const [loading, setLoading] = useState(true);
const [failed, setFailed] = useState(false);
const reload = useCallback(() => {
if (!enabled) return;
setFailed(false);
fetchReplyTemplates()
.then(setItems)
.catch(() => setFailed(true))
.finally(() => setLoading(false));
}, [enabled]);
useEffect(() => {
reload();
}, [reload]);
return { items, loading, failed, reload };
}
+19
View File
@@ -754,6 +754,10 @@ export const en: Record<MessageKey, Message> = {
"conversations.started": "Started",
"conversations.take_conversation": "Take the conversation",
"conversations.templates": "Templates",
"conversations.variable_client_name": "Customer name",
"conversations.variable_operator_name": "Operator name",
"conversations.variable_company": "Organization name",
"conversations.fill_template_variables": "Fill in the text: {names}",
"conversations.transcribe": "Transcribe",
"conversations.transcribing": "Transcribing…",
"conversations.type_message_shift_enter_line": "Type a message… Shift+Enter for a line break, “/” for a reply template",
@@ -820,6 +824,9 @@ export const en: Record<MessageKey, Message> = {
"portals.article_category_path_required": "Article #{index}: the required field “categoryPath” is a non-empty list of sections",
"portals.article_content_empty": "Article #{index}: the required field “content” is empty",
"portals.article_contents": "Article contents",
"portals.related_articles": "Related articles",
"portals.expand_section": "Expand section {name}",
"portals.collapse_section": "Collapse section {name}",
"portals.article_files": "ARTICLE FILES",
"portals.article_import": "Article import",
"portals.article_must_be_object": "Article #{index}: must be an object",
@@ -1474,6 +1481,18 @@ export const en: Record<MessageKey, Message> = {
"settings.queue_restore_defaults": "Restore the usual delays",
"settings.queue_policy_unavailable": "Could not load the delays",
"settings.queue_delivery_warning": "Reminders arrive where the employee will see them: the browser and the linked messenger. If a group has neither, only the counter in the conversation list is left.",
"settings.reply_templates": "Reply templates",
"settings.reply_templates_lead": "Ready-made replies for operators. In a conversation, type “/” in the message field to insert one.",
"settings.add_template": "Add a template",
"settings.no_templates_yet": "No templates yet. Add the first one — operators will call it up with “/”.",
"settings.could_not_load_templates": "Could not load the templates",
"settings.new_template": "New template",
"settings.edit_template": "Edit the template",
"settings.template_text": "Text",
"settings.insert_variable": "Insert a variable",
"settings.could_not_save_template": "Could not save the template",
"settings.delete_template": "Delete the template?",
"settings.template_will_be_deleted": "The template “{name}” will be deleted — operators will no longer be able to insert it.",
"settings.ai_provider": "AI provider",
"settings.allowed_domains": "Allowed domains",
"settings.answers_from_agent_s_knowledge": "Answers from the agent's knowledge with no external requests or keys. Answer quality is limited — for real work connect OpenRouter or a compatible provider.",
+19
View File
@@ -755,6 +755,10 @@ export const ru = {
"conversations.started": "Начат",
"conversations.take_conversation": "Взять диалог",
"conversations.templates": "Шаблоны",
"conversations.variable_client_name": "Имя клиента",
"conversations.variable_operator_name": "Имя оператора",
"conversations.variable_company": "Название организации",
"conversations.fill_template_variables": "Заполните в тексте: {names}",
"conversations.transcribe": "Расшифровать",
"conversations.transcribing": "Расшифровываем…",
"conversations.type_message_shift_enter_line": "Введите сообщение… Shift+Enter — перенос строки, «/» — шаблон ответа",
@@ -821,6 +825,9 @@ export const ru = {
"portals.article_category_path_required": "Статья #{index}: обязательное поле «categoryPath» — непустой список разделов",
"portals.article_content_empty": "Статья #{index}: обязательное поле «content» пустое",
"portals.article_contents": "Содержание статьи",
"portals.related_articles": "Связанные статьи",
"portals.expand_section": "Раскрыть раздел {name}",
"portals.collapse_section": "Свернуть раздел {name}",
"portals.article_files": "ФАЙЛЫ СТАТЬИ",
"portals.article_import": "Импорт статей",
"portals.article_must_be_object": "Статья #{index}: должна быть объектом",
@@ -1475,6 +1482,18 @@ export const ru = {
"settings.queue_restore_defaults": "Вернуть обычные сроки",
"settings.queue_policy_unavailable": "Не удалось загрузить сроки",
"settings.queue_delivery_warning": "Напоминания приходят туда, где сотрудник их получит: в браузер и в привязанный мессенджер. Если у группы не настроен ни один способ, останется только счётчик в списке диалогов.",
"settings.reply_templates": "Шаблоны ответов",
"settings.reply_templates_lead": "Готовые ответы для операторов. В диалоге шаблон вставляется, если набрать «/» в поле сообщения.",
"settings.add_template": "Добавить шаблон",
"settings.no_templates_yet": "Шаблонов пока нет. Добавьте первый — операторы вызовут его через «/».",
"settings.could_not_load_templates": "Не удалось загрузить шаблоны",
"settings.new_template": "Новый шаблон",
"settings.edit_template": "Изменить шаблон",
"settings.template_text": "Текст",
"settings.insert_variable": "Вставить переменную",
"settings.could_not_save_template": "Не удалось сохранить шаблон",
"settings.delete_template": "Удалить шаблон?",
"settings.template_will_be_deleted": "Шаблон «{name}» будет удалён — операторы больше не смогут его вставить.",
"settings.ai_provider": "AI-провайдер",
"settings.allowed_domains": "Разрешённые домены",
"settings.answers_from_agent_s_knowledge": "Отвечает по знаниям агента без внешних запросов и ключей. Качество ответов ограничено — для реальной работы подключите OpenRouter или совместимый провайдер.",
@@ -1,4 +1,4 @@
import { useRef, useState, type ReactNode } from "react";
import { useRef, useState, type ReactNode, type RefObject } from "react";
import { Icon } from "./icons";
import { SelectMenu } from "./ui-controls";
@@ -121,12 +121,13 @@ export function SelectField({ adornment, disabled = false, invalid = false, labe
);
}
export function TextAreaField({ disabled = false, label, value, onChange }: { disabled?: boolean; label: string; value: string; onChange: (value: string) => void }) {
/** `inputRef` — для вставки в позицию курсора (переменные шаблона ответа). */
export function TextAreaField({ disabled = false, label, value, onChange, inputRef }: { disabled?: boolean; label: string; value: string; onChange: (value: string) => void; inputRef?: RefObject<HTMLTextAreaElement | null> }) {
const className = ["readonly-field", "form-field", "wide", disabled ? "is-readonly is-disabled" : "is-editable"].join(" ");
return (
<label className={className}>
<span>{label}</span>
<textarea value={value} disabled={disabled} readOnly={disabled} onChange={(event) => onChange(event.target.value)} />
<textarea ref={inputRef} value={value} disabled={disabled} readOnly={disabled} onChange={(event) => onChange(event.target.value)} />
</label>
);
}
+23 -9
View File
@@ -19,6 +19,7 @@ import {
type WebConfig,
type WebMessage,
} from "./api";
import { BrandFooter } from "./BrandFooter";
import { CallInviteBanner, ChatBody, ChatComposer, ChatHeader, StartChatFooter } from "./ChatView";
import { useScrollToLatest } from "./useScrollToLatest";
import { useVoiceRecorder } from "./useVoiceRecorder";
@@ -36,6 +37,11 @@ function closePanel() {
window.parent.postMessage({ type: "chatballs-chat-close" }, "*");
}
/** Размер окна держит лоадер: панель живёт в iframe и сама себя не растянет. */
function requestExpanded(expanded: boolean) {
window.parent.postMessage({ type: "chatballs-chat-expand", expanded }, "*");
}
export function App() {
const [config, setConfig] = useState<WebConfig | null>(null);
const [token, setToken] = useState<string | null>(() => localStorage.getItem(TOKEN_KEY));
@@ -44,6 +50,10 @@ export function App() {
const [pending, setPending] = useState<string[]>([]);
const [state, setState] = useState<"ai" | "operator" | "waiting">("ai");
const [awaiting, setAwaiting] = useState(false);
// Ответ считается на сервере и приедет следующим опросом: до тех пор в ленте
// висит «печатает».
const [thinking, setThinking] = useState(false);
const [expanded, setExpanded] = useState(false);
const [input, setInput] = useState("");
const [starting, setStarting] = useState(false);
const [contactSent, setContactSent] = useState(false);
@@ -88,6 +98,7 @@ export function App() {
setContactSent(false);
}
setState(data.state);
setThinking(Boolean(data.thinking));
setCall(data.call?.callId === openedCallId.current ? null : (data.call ?? null));
if (!data.messages.length) return;
if (notify && data.messages.some((message) => message.author === "ai" || message.author === "operator")) notifyNewMessage();
@@ -124,7 +135,13 @@ export function App() {
const accent = config?.accent || "#1677ff";
const title = config?.title || t("chat.chat");
const letter = title.trim()[0]?.toUpperCase() || "E";
function toggleExpanded() {
setExpanded((previous) => {
requestExpanded(!previous);
return !previous;
});
}
function forgetSession() {
localStorage.removeItem(TOKEN_KEY);
@@ -135,6 +152,7 @@ export function App() {
setMessages([]);
setPending([]);
setAwaiting(false);
setThinking(false);
setCall(null);
setContactSent(false);
}
@@ -196,20 +214,16 @@ export function App() {
const lastContactRequestId = messages.reduce((current, message) => message.kind === "contact_request" ? message.id : current, 0);
const showPhoneForm = lastContactRequestId > 0 && !(contactSent || messages.some((message) => message.kind === "contact"));
const status = state === "operator"
? { label: t("chat.operator_answering"), dot: "#52c41a" }
: state === "waiting"
? { label: t("chat.passing_to_operator"), dot: "#faad14" }
: { label: t("chat.virtual_assistant"), dot: "#52c41a" };
const unavailable = config !== null && !config.available;
return (
<div style={{ display: "flex", flexDirection: "column", height: "100%", width: "100%", background: "#fff", fontFamily: "-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica Neue',Arial,sans-serif", color: "#1f1f1f", overflow: "hidden" }}>
<ChatHeader accent={accent} letter={letter} title={title} statusLabel={status.label} statusDot={status.dot} unavailable={unavailable} onClose={closePanel} />
<ChatBody bodyRef={bodyRef} config={config} unavailable={unavailable} accepted={accepted} accent={accent} letter={letter} title={title} messages={messages} pending={pending} awaiting={awaiting} lastContactRequestId={lastContactRequestId} showPhoneForm={showPhoneForm} onSubmitContact={submitContact} audioUrlFor={token ? (id) => voiceAudioUrl(token, id) : undefined} attachmentUrlFor={token ? (id, inline) => attachmentUrl(token, id, inline) : undefined} />
<ChatHeader accent={accent} title={title} expanded={expanded} onToggleExpand={toggleExpanded} onClose={closePanel} />
<ChatBody bodyRef={bodyRef} config={config} unavailable={unavailable} accepted={accepted} accent={accent} title={title} messages={messages} pending={pending} awaiting={awaiting || thinking} lastContactRequestId={lastContactRequestId} showPhoneForm={showPhoneForm} onSubmitContact={submitContact} audioUrlFor={token ? (id) => voiceAudioUrl(token, id) : undefined} attachmentUrlFor={token ? (id, inline) => attachmentUrl(token, id, inline) : undefined} />
{config?.available && accepted && call && (call.status === "REQUESTED" || call.status === "RINGING") && <CallInviteBanner call={call} accent={accent} onAccept={() => void acceptCallInvite()} onDecline={() => void declineCallInvite()} />}
{config?.available && !accepted && <StartChatFooter accent={accent} starting={starting} onAccept={() => void accept()} />}
{config?.available && accepted && <ChatComposer accent={accent} state={state} quickReplies={config.quickReplies ?? []} pendingCount={pending.length} messageCount={messages.length} input={input} onInput={setInput} onSend={() => void send()} voice={config.features?.voiceMessages === false ? undefined : recorder} attachment={{ file: attachment, errorText: attachmentError, pick: (file) => { if (!file) return; if (file.size > MAX_FILE_BYTES) { setAttachmentError(t("chat.file_too_big")); return; } setAttachmentError(""); setAttachment(file); }, clear: () => setAttachment(null) }} />}
{config?.available && accepted && <ChatComposer accent={accent} input={input} onInput={setInput} onSend={() => void send()} voice={config.features?.voiceMessages === false ? undefined : recorder} attachment={{ file: attachment, errorText: attachmentError, pick: (file) => { if (!file) return; if (file.size > MAX_FILE_BYTES) { setAttachmentError(t("chat.file_too_big")); return; } setAttachmentError(""); setAttachment(file); }, clear: () => setAttachment(null) }} />}
{config?.available && <BrandFooter accent={accent} />}
</div>
);
}
+38
View File
@@ -0,0 +1,38 @@
import { useId } from "react";
/** Знак агента (утверждён владельцем 20.09.2026).
*
* Глаза вырезаны маской и моргают одной группой — синхронно, раз в четыре с
* небольшим секунды. Маска получает свой id на каждый экземпляр: с общим id
* все знаки на странице смотрели бы в одну маску, и размонтирование соседа
* ломало бы остальные.
*/
export function BotIcon({ size = 24, color = "currentColor" }: { size?: number; color?: string }) {
const maskId = useId();
return (
<svg width={size} height={size} viewBox="0 0 48 48" fill={color} aria-hidden="true" style={{ display: "block" }}>
<mask id={maskId} maskUnits="userSpaceOnUse" x="0" y="0" width="48" height="48">
<rect width="48" height="48" fill="#fff" />
<g style={{ transformOrigin: "24px 20px", animation: "wcBotBlink 4.2s infinite" }}>
<rect x="15" y="16.4" width="4" height="7.2" rx="2" fill="#000" />
<rect x="29" y="16.4" width="4" height="7.2" rx="2" fill="#000" />
</g>
<path d="M17.6 26.4c1.7 2.4 3.9 3.6 6.4 3.6s4.7-1.2 6.4-3.6" fill="none" stroke="#000" strokeWidth="2.6" strokeLinecap="round" />
</mask>
<circle cx="24" cy="3.6" r="2.4" />
<rect x="22.8" y="4.8" width="2.4" height="5" />
<rect x="2.2" y="18" width="2.6" height="8.4" rx="1.3" />
<rect x="43.2" y="18" width="2.6" height="8.4" rx="1.3" />
<path mask={`url(#${maskId})`} d="M14 9.2h20a6.8 6.8 0 0 1 6.8 6.8v12.4a6.8 6.8 0 0 1-6.8 6.8H21.6l-6.4 5.2a.9.9 0 0 1-1.5-.7v-4.5h-.3A6.8 6.8 0 0 1 7.2 28.4V16A6.8 6.8 0 0 1 14 9.2Z" />
</svg>
);
}
/** Круглый аватар агента: фон — акцент канала, внутри знак. */
export function BotAvatar({ size = 30, accent, color = "#fff" }: { size?: number; accent: string; color?: string }) {
return (
<span style={{ width: size, height: size, borderRadius: "50%", background: accent, display: "inline-flex", alignItems: "center", justifyContent: "center", flex: "none" }}>
<BotIcon size={Math.round(size * 0.62)} color={color} />
</span>
);
}
+39
View File
@@ -0,0 +1,39 @@
import { useState } from "react";
import { t } from "./i18n";
const SITE = "https://chatballs.ru";
/** Подпись под полем ввода: чей это чат.
*
* Ссылка открывается новой вкладкой: виджет живёт в iframe на чужой странице,
* и уводить эту страницу с собой он не должен. Наведение подсвечивает подпись
* акцентом канала — состояние держится в React: вокруг только inline-стили,
* а :hover в них не живёт.
*/
export function BrandFooter({ accent }: { accent: string }) {
const [hover, setHover] = useState(false);
return (
<div style={{ flex: "none", background: "#f7f8fa", padding: "7px 14px 10px", display: "flex", justifyContent: "center" }}>
<a
href={SITE}
target="_blank"
rel="noopener noreferrer"
onMouseEnter={() => setHover(true)}
onMouseLeave={() => setHover(false)}
style={{
display: "inline-flex",
alignItems: "baseline",
gap: 4,
fontSize: 13,
lineHeight: 1,
textDecoration: "none",
transition: "color .18s ease",
}}
>
<span style={{ fontWeight: 400, color: hover ? accent : "#8c8c8c", transition: "color .18s ease" }}>{t("chat.powered_by")}</span>
<span style={{ fontWeight: 700, color: hover ? accent : "#262626", transition: "color .18s ease" }}>Chatballs</span>
</a>
</div>
);
}
+123 -46
View File
@@ -1,6 +1,7 @@
import { useEffect, useRef, useState, type RefObject } from "react";
import { isVideoCall, type CallInfo, type WebConfig, type WebMessage } from "./api";
import { BotAvatar, BotIcon } from "./BotIcon";
import { VoiceMessage } from "./VoiceMessage";
import type { useVoiceRecorder } from "./useVoiceRecorder";
import { fmt, t } from "./i18n";
@@ -11,31 +12,43 @@ function formatSeconds(total: number): string {
return `${Math.floor(total / 60)}:${String(total % 60).padStart(2, "0")}`;
}
export function ChatHeader({ accent, letter, title, statusLabel, statusDot, unavailable, onClose }: { accent: string; letter: string; title: string; statusLabel: string; statusDot: string; unavailable: boolean; onClose: () => void }) {
const HEADER_BUTTON: React.CSSProperties = {
width: 30,
height: 30,
borderRadius: 9,
border: "none",
background: "rgba(255,255,255,0.14)",
display: "flex",
alignItems: "center",
justifyContent: "center",
cursor: "pointer",
flex: "none",
color: "#fff",
};
export function ChatHeader({ accent, title, expanded, onToggleExpand, onClose }: { accent: string; title: string; expanded: boolean; onToggleExpand: () => void; onClose: () => void }) {
return (
<div style={{ flex: "none", background: accent, padding: "14px 16px", display: "flex", alignItems: "center", gap: 11 }}>
<div style={{ width: 36, height: 36, borderRadius: 10, background: "rgba(255,255,255,0.18)", display: "flex", alignItems: "center", justifyContent: "center", flex: "none", fontSize: 15, fontWeight: 700, color: "#fff" }}>{letter}</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 14.5, fontWeight: 600, color: "#fff", lineHeight: 1.2 }}>{title}</div>
<div style={{ display: "flex", alignItems: "center", gap: 6, marginTop: 2 }}>
<span style={{ width: 7, height: 7, borderRadius: "50%", background: statusDot, flex: "none" }} />
<span style={{ fontSize: 11.5, color: "rgba(255,255,255,0.92)" }}>{unavailable ? t("chat.temporarily_unavailable") : statusLabel}</span>
</div>
</div>
<button onClick={onClose} aria-label={t("chat.collapse")} style={{ width: 30, height: 30, borderRadius: 8, border: "none", background: "rgba(255,255,255,0.14)", display: "flex", alignItems: "center", justifyContent: "center", cursor: "pointer", flex: "none" }}>
<svg viewBox="0 0 24 24" width="17" height="17" fill="none" stroke="#fff" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round"><line x1="5" y1="12" x2="19" y2="12" /></svg>
<span style={{ display: "flex", flex: "none", color: "#fff" }}><BotIcon size={34} /></span>
<div style={{ flex: 1, minWidth: 0, fontSize: 16, fontWeight: 600, color: "#fff", lineHeight: 1.25, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{title}</div>
<button className="wc-icon-button" onClick={onToggleExpand} aria-label={expanded ? t("chat.shrink_panel") : t("chat.expand_panel")} style={HEADER_BUTTON}>
{expanded
? <svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" strokeWidth="2.1" strokeLinecap="round" strokeLinejoin="round"><path d="M9 3v4a2 2 0 0 1-2 2H3" /><path d="M15 3v4a2 2 0 0 0 2 2h4" /><path d="M9 21v-4a2 2 0 0 0-2-2H3" /><path d="M15 21v-4a2 2 0 0 1 2-2h4" /></svg>
: <svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" strokeWidth="2.1" strokeLinecap="round" strokeLinejoin="round"><path d="M8 3H5a2 2 0 0 0-2 2v3" /><path d="M16 3h3a2 2 0 0 1 2 2v3" /><path d="M8 21H5a2 2 0 0 1-2-2v-3" /><path d="M16 21h3a2 2 0 0 0 2-2v-3" /></svg>}
</button>
<button className="wc-icon-button" onClick={onClose} aria-label={t("chat.collapse")} style={HEADER_BUTTON}>
<svg viewBox="0 0 24 24" width="17" height="17" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round"><line x1="5" y1="12" x2="19" y2="12" /></svg>
</button>
</div>
);
}
export function ChatBody({ bodyRef, config, unavailable, accepted, accent, letter, title, messages, pending, awaiting, lastContactRequestId, showPhoneForm, onSubmitContact, audioUrlFor, attachmentUrlFor }: {
export function ChatBody({ bodyRef, config, unavailable, accepted, accent, title, messages, pending, awaiting, lastContactRequestId, showPhoneForm, onSubmitContact, audioUrlFor, attachmentUrlFor }: {
bodyRef: RefObject<HTMLDivElement | null>;
config: WebConfig | null;
unavailable: boolean;
accepted: boolean;
accent: string;
letter: string;
title: string;
messages: WebMessage[];
pending: string[];
@@ -50,16 +63,16 @@ export function ChatBody({ bodyRef, config, unavailable, accepted, accent, lette
<div ref={bodyRef} style={{ flex: 1, minHeight: 0, overflowY: "auto", background: "#f7f8fa", padding: "18px 16px" }}>
{config === null && <div style={{ textAlign: "center", color: "#8c8c8c", padding: 24, fontSize: 13 }}>{t("chat.loading")}</div>}
{unavailable && <Unavailable />}
{config?.available && !accepted && <Consent config={config} accent={accent} letter={letter} title={title} />}
{config?.available && !accepted && <Consent config={config} accent={accent} title={title} />}
{config?.available && accepted && (
<>
<div style={{ textAlign: "center", marginBottom: 14 }}><span style={{ display: "inline-block", padding: "3px 11px", borderRadius: 20, background: "#eef0f2", fontSize: 11, color: "#8c8c8c" }}>{t("chat.today")}</span></div>
{config.greeting && <Bubble author="ai" text={config.greeting} accent={accent} />}
{config.greeting && <Bubble author="ai" text={config.greeting} accent={accent} authorName={title} />}
{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} 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 />}
: <div key={message.id}><Bubble author={message.author} authorName={title} 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" authorName={title} text={text} accent={accent} pendingState />)}
{awaiting && <Typing accent={accent} authorName={title} />}
</>
)}
</div>
@@ -70,8 +83,8 @@ function Unavailable() {
return <div style={{ display: "flex", flexDirection: "column", alignItems: "center", textAlign: "center", padding: "26px 14px" }}><div style={{ width: 52, height: 52, borderRadius: "50%", background: "#fff7e6", display: "flex", alignItems: "center", justifyContent: "center", marginBottom: 14 }}><svg viewBox="0 0 24 24" width="26" height="26" fill="none" stroke="#d48806" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M10.29 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0Z" /><line x1="12" y1="9" x2="12" y2="13" /><line x1="12" y1="17" x2="12.01" y2="17" /></svg></div><h3 style={{ margin: 0, fontSize: 15, fontWeight: 700 }}>{t("chat.unavailable_title")}</h3><p style={{ margin: "8px 0 0", fontSize: 13, color: "#595959", lineHeight: 1.5, maxWidth: 280 }}>{t("chat.unavailable_body")}</p></div>;
}
function Consent({ config, accent, letter, title }: { config: WebConfig; accent: string; letter: string; title: string }) {
return <><div style={{ display: "flex", flexDirection: "column", alignItems: "center", textAlign: "center", padding: "20px 12px 8px" }}><div style={{ width: 56, height: 56, borderRadius: 16, background: "#e6f4ff", display: "flex", alignItems: "center", justifyContent: "center", marginBottom: 14 }}><span style={{ fontSize: 24, fontWeight: 700, color: accent }}>{letter}</span></div><h3 style={{ margin: 0, fontSize: 17, fontWeight: 700 }}>{title}</h3><p style={{ margin: "8px 0 0", fontSize: 13, color: "#595959", lineHeight: 1.5, maxWidth: 280 }}>{config.greeting}</p></div><div style={{ marginTop: 18, background: "#fff", border: "1px solid #f0f0f0", borderRadius: 12, padding: 14 }}><div style={{ fontSize: 12, color: "#595959", lineHeight: 1.5 }}>{config.consent?.text} · {t("chat.consent_revision", { version: config.consent?.version ?? "" })}</div></div></>;
function Consent({ config, accent, title }: { config: WebConfig; accent: string; title: string }) {
return <><div style={{ display: "flex", flexDirection: "column", alignItems: "center", textAlign: "center", padding: "20px 12px 8px" }}><div style={{ marginBottom: 14 }}><BotAvatar size={56} accent={accent} /></div><h3 style={{ margin: 0, fontSize: 17, fontWeight: 700 }}>{title}</h3><p style={{ margin: "8px 0 0", fontSize: 15.5, lineHeight: "22px", color: "#595959", maxWidth: 300 }}>{config.greeting}</p></div><div style={{ marginTop: 18, background: "#fff", border: "1px solid #f0f0f0", borderRadius: 12, padding: 14 }}><div style={{ fontSize: 12, color: "#595959", lineHeight: 1.5 }}>{config.consent?.text} · {t("chat.consent_revision", { version: config.consent?.version ?? "" })}</div></div></>;
}
export function SystemMessage({ text }: { text: string }) {
@@ -79,45 +92,73 @@ export function SystemMessage({ text }: { text: string }) {
}
export function StartChatFooter({ accent, starting, onAccept }: { accent: string; starting: boolean; onAccept: () => void }) {
return <div style={{ flex: "none", background: "#fff", borderTop: "1px solid #f0f0f0", padding: "12px 14px 14px" }}><button onClick={onAccept} disabled={starting} style={{ width: "100%", height: 44, borderRadius: 10, border: "none", background: accent, color: "#fff", fontSize: 14, fontWeight: 600, cursor: "pointer" }}>{starting ? t("chat.starting") : t("chat.accept_and_start")}</button></div>;
return <div style={{ flex: "none", background: FOOTER_BG, borderTop: `1px solid ${FOOTER_CONTROL}`, padding: "12px 14px 10px" }}><button onClick={onAccept} disabled={starting} style={{ width: "100%", height: 44, borderRadius: 10, border: "none", background: accent, color: "#fff", fontSize: 14, fontWeight: 600, cursor: "pointer" }}>{starting ? t("chat.starting") : t("chat.accept_and_start")}</button></div>;
}
const COMPOSER_MAX_HEIGHT = 132;
export type ComposerAttachment = { file: File | null; errorText: string; pick: (file: File | null) => void; clear: () => void };
export function ChatComposer({ accent, state, quickReplies, pendingCount, messageCount, input, placeholder, onInput, onSend, voice, attachment }: { accent: string; state: "ai" | "operator" | "waiting"; quickReplies: string[]; pendingCount: number; messageCount: number; input: string; placeholder?: string; onInput: (value: string) => void; onSend: () => void; voice?: ComposerVoice; attachment?: ComposerAttachment }) {
// Подвал панели: фон как у ленты, а его элементы — на пару тонов темнее.
const FOOTER_BG = "#f7f8fa";
const FOOTER_CONTROL = "#e6e9ed";
const ICON_BUTTON: React.CSSProperties = {
width: 32,
height: 32,
borderRadius: "50%",
border: "none",
background: "transparent",
display: "flex",
alignItems: "center",
justifyContent: "center",
cursor: "pointer",
flex: "none",
color: "#8c8c8c",
};
export function ChatComposer({ accent, input, placeholder, onInput, onSend, voice, attachment }: { accent: string; input: string; placeholder?: string; onInput: (value: string) => void; onSend: () => void; voice?: ComposerVoice; attachment?: ComposerAttachment }) {
const textareaRef = useRef<HTMLTextAreaElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
// Пока клиент не начал печатать, справа — запись голосового; как только в
// поле появился текст, её место занимает отправка.
const typing = Boolean(input.trim()) || Boolean(attachment?.file);
// Поле растёт под текст, как в мессенджере (до COMPOSER_MAX_HEIGHT, дальше скролл).
useEffect(() => {
const node = textareaRef.current;
if (!node) return;
node.style.height = "auto";
// Панель может быть ещё скрыта (у iframe display:none) — тогда scrollHeight
// равен нулю и высоту задавать нельзя, иначе поле схлопнется.
node.style.height = node.scrollHeight > 0 ? `${Math.min(node.scrollHeight, COMPOSER_MAX_HEIGHT)}px` : "";
// Мерить можно только разложенное поле. У скрытой панели (display:none у
// iframe) scrollHeight равен нулю, а у ещё не разложенной ширина нулевая и
// placeholder переносится по букве — тогда замер даёт максимум, и поле
// остаётся растянутым на всю высоту. В обоих случаях высоту не трогаем:
// пустое поле и так в одну строку.
if (!input || node.clientWidth === 0 || node.scrollHeight === 0) {
node.style.height = "";
return;
}
node.style.height = `${Math.min(node.scrollHeight, COMPOSER_MAX_HEIGHT)}px`;
}, [input]);
if (voice && voice.state !== "idle") {
// Режим записи (кадр H, упрощённый для виджета): корзина · таймер · отправить.
const sending = voice.state === "sending";
return (
<div style={{ flex: "none", background: "#fff", borderTop: "1px solid #f0f0f0", padding: "12px 14px 14px" }}>
<div style={{ display: "flex", alignItems: "center", gap: 10, border: "1px solid #e8e8e8", borderRadius: 14, padding: "6px 6px 6px 10px", minHeight: 48, boxSizing: "border-box" }}>
<button onClick={voice.cancel} disabled={sending} aria-label={t("chat.cancel_recording")} style={{ width: 34, height: 34, borderRadius: 9, border: "none", background: "transparent", display: "flex", alignItems: "center", justifyContent: "center", cursor: "pointer", flex: "none" }}><svg viewBox="0 0 24 24" width="17" height="17" fill="none" stroke="#8c8c8c" strokeWidth="1.9" strokeLinecap="round" strokeLinejoin="round"><path d="M3 6h18" /><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6" /><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" /></svg></button>
<div style={{ flex: "none", background: FOOTER_BG, borderTop: `1px solid ${FOOTER_CONTROL}`, padding: "8px 12px" }}>
<div style={{ display: "flex", alignItems: "center", gap: 10, minHeight: 44, boxSizing: "border-box" }}>
<button className="wc-icon-button" onClick={voice.cancel} disabled={sending} aria-label={t("chat.cancel_recording")} style={ICON_BUTTON}><svg viewBox="0 0 24 24" width="17" height="17" fill="none" stroke="currentColor" strokeWidth="1.9" strokeLinecap="round" strokeLinejoin="round"><path d="M3 6h18" /><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6" /><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" /></svg></button>
<span style={{ width: 8, height: 8, borderRadius: "50%", background: "#f5222d", flex: "none", animation: "wcTyping 1.2s infinite ease-in-out" }} />
<span style={{ fontSize: 13.5, fontWeight: 600, color: "#f5222d", fontVariantNumeric: "tabular-nums" }}>{formatSeconds(voice.seconds)}</span>
<span style={{ flex: 1, fontSize: 12.5, color: "#8c8c8c" }}>{sending ? t("chat.sending") : t("chat.recording")}</span>
<button onClick={voice.stopAndSend} disabled={sending} aria-label={t("chat.send_voice")} style={{ width: 36, height: 36, borderRadius: 10, border: "none", background: accent, display: "flex", alignItems: "center", justifyContent: "center", cursor: "pointer", flex: "none", opacity: sending ? 0.6 : 1 }}><svg viewBox="0 0 24 24" width="17" height="17" fill="none" stroke="#fff" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><line x1="22" y1="2" x2="11" y2="13" /><polygon points="22 2 15 22 11 13 2 9 22 2" /></svg></button>
<button className="wc-icon-button" onClick={voice.stopAndSend} disabled={sending} aria-label={t("chat.send_voice")} style={{ ...ICON_BUTTON, background: accent, color: "#fff", opacity: sending ? 0.6 : 1 }}><svg viewBox="0 0 24 24" width="17" height="17" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><line x1="12" y1="19" x2="12" y2="5" /><polyline points="5 12 12 5 19 12" /></svg></button>
</div>
</div>
);
}
return (
<div style={{ flex: "none", background: "#fff", borderTop: "1px solid #f0f0f0", padding: "12px 14px 14px" }}>
<div style={{ flex: "none", background: FOOTER_BG, borderTop: `1px solid ${FOOTER_CONTROL}`, padding: "8px 12px" }}>
{voice?.errorText && <div style={{ marginBottom: 8, fontSize: 12, color: "#cf1322" }}>{voice.errorText}</div>}
{attachment?.errorText && <div style={{ marginBottom: 8, fontSize: 12, color: "#cf1322" }}>{attachment.errorText}</div>}
{attachment?.file && (
@@ -128,19 +169,17 @@ export function ChatComposer({ accent, state, quickReplies, pendingCount, messag
<button onClick={attachment.clear} aria-label={t("chat.remove_file")} style={{ border: "none", background: "transparent", padding: 0, display: "flex", cursor: "pointer", color: "#8c8c8c", flex: "none" }}><svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" strokeWidth="1.9" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="10" /><path d="m15 9-6 6M9 9l6 6" /></svg></button>
</div>
)}
{state === "ai" && quickReplies.length > 0 && pendingCount === 0 && messageCount === 0 && <div style={{ display: "flex", gap: 7, flexWrap: "wrap", marginBottom: 10 }}>{quickReplies.map((reply) => <button key={reply} onClick={() => onInput(reply)} style={{ padding: "7px 13px", borderRadius: 16, border: "1px solid #d6e4ff", background: "#f0f7ff", color: "#0958d9", fontSize: 12.5, fontWeight: 500, cursor: "pointer" }}>{reply}</button>)}</div>}
<div style={{ display: "flex", alignItems: "flex-end", gap: 8, border: "1px solid #e8e8e8", borderRadius: 14, padding: "6px 6px 6px 14px", background: "#fff" }}>
<textarea ref={textareaRef} rows={1} value={input} onChange={(event) => onInput(event.target.value)} onKeyDown={(event) => { if (event.key === "Enter" && !event.shiftKey) { event.preventDefault(); onSend(); } }} placeholder={placeholder ?? t("chat.message_placeholder")} style={{ flex: 1, border: "none", outline: "none", resize: "none", fontSize: 14, lineHeight: 1.5, color: "#262626", fontFamily: "inherit", padding: "7px 0", maxHeight: COMPOSER_MAX_HEIGHT }} />
<div style={{ display: "flex", alignItems: "flex-start", gap: 4 }}>
{attachment && (
<>
<button onClick={() => fileInputRef.current?.click()} aria-label={t("chat.attach_file")} style={{ width: 36, height: 36, borderRadius: 10, border: "none", background: "transparent", display: "flex", alignItems: "center", justifyContent: "center", cursor: "pointer", flex: "none" }}><svg viewBox="0 0 24 24" width="17" height="17" fill="none" stroke="#8c8c8c" strokeWidth="1.9" strokeLinecap="round" strokeLinejoin="round"><path d="m21.44 11.05-9.19 9.19a6 6 0 0 1-8.49-8.49l8.57-8.57A4 4 0 1 1 18 8.84l-8.59 8.57a2 2 0 0 1-2.83-2.83l8.49-8.48" /></svg></button>
<button className="wc-icon-button" onClick={() => fileInputRef.current?.click()} aria-label={t("chat.attach_file")} style={ICON_BUTTON}><svg viewBox="0 0 24 24" width="17" height="17" fill="none" stroke="currentColor" strokeWidth="1.9" strokeLinecap="round" strokeLinejoin="round"><path d="m21.44 11.05-9.19 9.19a6 6 0 0 1-8.49-8.49l8.57-8.57A4 4 0 1 1 18 8.84l-8.59 8.57a2 2 0 0 1-2.83-2.83l8.49-8.48" /></svg></button>
<input ref={fileInputRef} type="file" hidden onChange={(event) => { attachment.pick(event.target.files?.[0] ?? null); event.target.value = ""; }} />
</>
)}
{voice?.supported && !input.trim() && !attachment?.file && (
<button onClick={() => void voice.start()} aria-label={t("chat.record_voice")} style={{ width: 36, height: 36, borderRadius: 10, border: "none", background: "transparent", display: "flex", alignItems: "center", justifyContent: "center", cursor: "pointer", flex: "none" }}><svg viewBox="0 0 24 24" width="17" height="17" fill="none" stroke="#8c8c8c" strokeWidth="1.9" strokeLinecap="round" strokeLinejoin="round"><path d="M12 2a3 3 0 0 0-3 3v7a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3Z" /><path d="M19 10v2a7 7 0 0 1-14 0v-2" /><line x1="12" y1="19" x2="12" y2="22" /></svg></button>
)}
<button onClick={onSend} aria-label={t("chat.send")} style={{ width: 36, height: 36, borderRadius: 10, border: "none", background: accent, display: "flex", alignItems: "center", justifyContent: "center", cursor: "pointer", flex: "none" }}><svg viewBox="0 0 24 24" width="17" height="17" fill="none" stroke="#fff" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><line x1="22" y1="2" x2="11" y2="13" /><polygon points="22 2 15 22 11 13 2 9 22 2" /></svg></button>
<textarea ref={textareaRef} rows={1} value={input} onChange={(event) => onInput(event.target.value)} onKeyDown={(event) => { if (event.key === "Enter" && !event.shiftKey) { event.preventDefault(); onSend(); } }} placeholder={placeholder ?? t("chat.message_placeholder")} style={{ flex: 1, border: "none", outline: "none", resize: "none", background: "transparent", fontSize: 15, lineHeight: "21px", color: "#262626", fontFamily: "inherit", padding: "8px 2px", maxHeight: COMPOSER_MAX_HEIGHT }} />
{voice?.supported && !typing
? <button className="wc-icon-button" onClick={() => void voice.start()} aria-label={t("chat.record_voice")} style={{ ...ICON_BUTTON, background: FOOTER_CONTROL, color: "#595959" }}><svg viewBox="0 0 24 24" width="17" height="17" fill="none" stroke="currentColor" strokeWidth="1.9" strokeLinecap="round" strokeLinejoin="round"><path d="M12 2a3 3 0 0 0-3 3v7a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3Z" /><path d="M19 10v2a7 7 0 0 1-14 0v-2" /><line x1="12" y1="19" x2="12" y2="22" /></svg></button>
: <button className="wc-icon-button" onClick={onSend} aria-label={t("chat.send")} style={{ ...ICON_BUTTON, background: accent, color: "#fff", transition: "background .18s ease" }}><svg viewBox="0 0 24 24" width="17" height="17" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><line x1="12" y1="19" x2="12" y2="5" /><polyline points="5 12 12 5 19 12" /></svg></button>}
</div>
</div>
);
@@ -153,7 +192,7 @@ export function CallInviteBanner({ call, accent, onAccept, onDecline }: { call:
? <svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="#fff" strokeWidth="1.9" strokeLinecap="round" strokeLinejoin="round"><path d="M23 7l-7 5 7 5V7z" /><rect x="1" y="5" width="15" height="14" rx="2.5" /></svg>
: <svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="#fff" strokeWidth="1.9" strokeLinecap="round" strokeLinejoin="round"><path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72c.13.81.36 1.6.7 2.34a2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.74-1.27a2 2 0 0 1 2.11-.45c.74.34 1.53.57 2.34.7A2 2 0 0 1 22 16.92z" /></svg>;
const title = isVideo ? t("chat.incoming_video_call") : t("chat.incoming_audio_call");
return <div style={{ flex: "none", background: "#fff", borderTop: "1px solid #f0f0f0", padding: "12px 14px", display: "flex", alignItems: "center", gap: 12 }}><div style={{ width: 40, height: 40, borderRadius: "50%", background: accent, display: "flex", alignItems: "center", justifyContent: "center", flex: "none" }}>{inviteIcon}</div><div style={{ flex: 1, minWidth: 0 }}><div style={{ fontSize: 13.5, fontWeight: 600, color: "#1f1f1f" }}>{title}</div><div style={{ fontSize: 12, color: "#8c8c8c", marginTop: 2, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>{t("chat.invites_you_to_call", { name: call.staffName || t("call.operator") })}</div></div><button onClick={onDecline} aria-label={t("call.decline")} style={{ width: 42, height: 42, borderRadius: "50%", border: "none", background: "#ff4d4f", display: "flex", alignItems: "center", justifyContent: "center", cursor: "pointer", flex: "none", boxShadow: "0 2px 8px rgba(255,77,79,.35)" }}>{phoneIcon(true)}</button><button onClick={onAccept} aria-label={t("call.accept")} style={{ width: 42, height: 42, borderRadius: "50%", border: "none", background: "#52c41a", display: "flex", alignItems: "center", justifyContent: "center", cursor: "pointer", flex: "none", boxShadow: "0 2px 8px rgba(82,196,26,.35)" }}>{phoneIcon(false)}</button></div>;
return <div style={{ flex: "none", background: FOOTER_BG, borderTop: `1px solid ${FOOTER_CONTROL}`, padding: "12px 14px", display: "flex", alignItems: "center", gap: 12 }}><div style={{ width: 40, height: 40, borderRadius: "50%", background: accent, display: "flex", alignItems: "center", justifyContent: "center", flex: "none" }}>{inviteIcon}</div><div style={{ flex: 1, minWidth: 0 }}><div style={{ fontSize: 13.5, fontWeight: 600, color: "#1f1f1f" }}>{title}</div><div style={{ fontSize: 12, color: "#8c8c8c", marginTop: 2, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>{t("chat.invites_you_to_call", { name: call.staffName || t("call.operator") })}</div></div><button onClick={onDecline} aria-label={t("call.decline")} style={{ width: 42, height: 42, borderRadius: "50%", border: "none", background: "#ff4d4f", display: "flex", alignItems: "center", justifyContent: "center", cursor: "pointer", flex: "none", boxShadow: "0 2px 8px rgba(255,77,79,.35)" }}>{phoneIcon(true)}</button><button onClick={onAccept} aria-label={t("call.accept")} style={{ width: 42, height: 42, borderRadius: "50%", border: "none", background: "#52c41a", display: "flex", alignItems: "center", justifyContent: "center", cursor: "pointer", flex: "none", boxShadow: "0 2px 8px rgba(82,196,26,.35)" }}>{phoneIcon(false)}</button></div>;
}
function formatTime(iso: string | undefined): string {
@@ -203,16 +242,44 @@ function AttachmentContent({ attachment, text, light }: { attachment: BubbleAtta
);
}
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 }) {
/** Строка автора над пузырём: аватар и имя — как на утверждённом образце. */
function AuthorRow({ accent, name, operator }: { accent: string; name: string; operator: boolean }) {
return (
<div style={{ display: "flex", alignItems: "center", gap: 8, marginBottom: 6 }}>
{operator
? <span style={{ width: 32, height: 32, borderRadius: "50%", background: accent, color: "#fff", display: "flex", alignItems: "center", justifyContent: "center", flex: "none", fontSize: 11, fontWeight: 600 }}>{t("chat.operator_initial")}</span>
: <BotAvatar size={32} accent={accent} />}
<span style={{ fontSize: 12.5, fontWeight: 600, color: "#595959" }}>{name}</span>
</div>
);
}
export function Bubble({ author, authorName, text, accent, time, pendingState, audioUrl, voice, attachment }: { author: "client" | "ai" | "operator"; authorName: string; text: string; accent: string; time?: string; pendingState?: boolean; audioUrl?: string; voice?: { messageId: number; durationSeconds?: number }; attachment?: BubbleAttachment }) {
const at = formatTime(time);
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;
if (author === "client") return <div style={{ display: "flex", justifyContent: "flex-end", marginBottom: 12 }}><div style={{ maxWidth: "82%" }}><div style={{ background: accent, color: "#fff", borderRadius: "16px 16px 4px 16px", padding: audioUrl ? "8px" : "10px 14px", fontSize: 14.5, lineHeight: 1.5, whiteSpace: "pre-wrap", wordBreak: "break-word" }}>{content}</div><div style={{ ...META, marginRight: 4, textAlign: "right" }}>{pendingState ? t("chat.sending_lower") : [at, t("chat.delivered")].filter(Boolean).join(" · ")}</div></div></div>;
const isOperator = author === "operator";
return <div style={{ display: "flex", gap: 9, marginBottom: 12 }}><div style={{ width: 30, height: 30, borderRadius: "50%", background: isOperator ? accent : "#eef0f2", border: isOperator ? "none" : "1px solid #e3e6ea", color: "#fff", display: "flex", alignItems: "center", justifyContent: "center", flex: "none" }}>{isOperator ? <span style={{ fontSize: 11.5, fontWeight: 600 }}>{t("chat.operator_initial")}</span> : <svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="#8c8c8c" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="8" r="3.2" /><path d="M5.5 20a6.5 6.5 0 0 1 13 0" /></svg>}</div><div style={{ maxWidth: "82%" }}><div style={{ background: "#fff", border: "1px solid #eee", borderRadius: "16px 16px 16px 4px", padding: audioUrl ? "8px" : "10px 14px", fontSize: 14.5, lineHeight: 1.5, color: "#262626", whiteSpace: "pre-wrap", wordBreak: "break-word" }}>{content}</div><div style={{ ...META, marginLeft: 4 }}>{[isOperator ? t("chat.specialist") : t("chat.virtual_assistant"), at].filter(Boolean).join(" · ")}</div></div></div>;
if (author === "client") {
return (
<div style={{ display: "flex", justifyContent: "flex-end", marginBottom: 14 }}>
<div style={{ maxWidth: "82%" }}>
<div style={{ background: accent, color: "#fff", borderRadius: "16px 4px 16px 16px", padding: audioUrl ? "8px" : "11px 15px", fontSize: 15.5, lineHeight: "22px", whiteSpace: "pre-wrap", wordBreak: "break-word" }}>{content}</div>
<div style={{ ...META, marginRight: 4, textAlign: "right" }}>{pendingState ? t("chat.sending_lower") : at}</div>
</div>
</div>
);
}
const operator = author === "operator";
return (
<div style={{ marginBottom: 14 }}>
<AuthorRow accent={accent} name={operator ? t("chat.specialist") : authorName} operator={operator} />
<div style={{ maxWidth: "88%" }}>
<div style={{ background: "#fff", border: "1px solid #eee", borderRadius: "4px 16px 16px 16px", padding: audioUrl ? "8px" : "11px 15px", fontSize: 15.5, lineHeight: "22px", color: "#262626", whiteSpace: "pre-wrap", wordBreak: "break-word" }}>{content}</div>
</div>
</div>
);
}
function formatPhone(raw: string): string {
@@ -236,9 +303,19 @@ function PhoneForm({ accent, onSubmit }: { accent: string; onSubmit: (phone: str
const [error, setError] = useState(false);
const valid = phone.replace(/\D/g, "").length === 11;
async function submit() { if (!valid || sending) return; setSending(true); setError(false); const ok = await onSubmit(phone); setSending(false); if (!ok) setError(true); }
return <div style={{ margin: "2px 0 10px 36px", maxWidth: "78%", background: "#fff", border: "1px solid #eee", borderRadius: 12, padding: 12 }}><input type="tel" inputMode="tel" value={phone} onChange={(event) => setPhone(formatPhone(event.target.value))} onKeyDown={(event) => { if (event.key === "Enter") { event.preventDefault(); void submit(); } }} placeholder="+7 (___) ___-__-__" style={{ width: "100%", boxSizing: "border-box", height: 38, borderRadius: 8, border: error ? "1px solid #ff4d4f" : "1px solid #d9d9d9", padding: "0 10px", outline: "none", fontSize: 13.5 }} /><button type="button" onClick={() => void submit()} disabled={!valid || sending} style={{ width: "100%", height: 36, marginTop: 8, borderRadius: 8, border: "none", background: valid ? accent : "#d9d9d9", color: "#fff", fontSize: 12.5, fontWeight: 600, cursor: valid ? "pointer" : "default" }}>{sending ? t("chat.sending") : t("chat.share_number")}</button>{error && <div style={{ marginTop: 6, color: "#cf1322", fontSize: 11.5 }}>{t("chat.share_number_failed")}</div>}</div>;
return <div style={{ margin: "2px 0 10px", maxWidth: "78%", background: "#fff", border: "1px solid #eee", borderRadius: 12, padding: 12 }}><input type="tel" inputMode="tel" value={phone} onChange={(event) => setPhone(formatPhone(event.target.value))} onKeyDown={(event) => { if (event.key === "Enter") { event.preventDefault(); void submit(); } }} placeholder="+7 (___) ___-__-__" style={{ width: "100%", boxSizing: "border-box", height: 38, borderRadius: 8, border: error ? "1px solid #ff4d4f" : "1px solid #d9d9d9", padding: "0 10px", outline: "none", fontSize: 13.5 }} /><button type="button" onClick={() => void submit()} disabled={!valid || sending} style={{ width: "100%", height: 36, marginTop: 8, borderRadius: 8, border: "none", background: valid ? accent : "#d9d9d9", color: "#fff", fontSize: 12.5, fontWeight: 600, cursor: valid ? "pointer" : "default" }}>{sending ? t("chat.sending") : t("chat.share_number")}</button>{error && <div style={{ marginTop: 6, color: "#cf1322", fontSize: 11.5 }}>{t("chat.share_number_failed")}</div>}</div>;
}
export function Typing() {
return <div style={{ display: "flex", gap: 9, marginBottom: 4 }}><div style={{ width: 30, height: 30, borderRadius: "50%", background: "#eef0f2", border: "1px solid #e3e6ea", display: "flex", alignItems: "center", justifyContent: "center", flex: "none" }}><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="#8c8c8c" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="8" r="3.2" /><path d="M5.5 20a6.5 6.5 0 0 1 13 0" /></svg></div><div style={{ background: "#fff", border: "1px solid #eee", borderRadius: "14px 14px 14px 4px", padding: "12px 14px", display: "flex", alignItems: "center", gap: 5 }}>{[0, 0.2, 0.4].map((delay) => <span key={delay} style={{ width: 6, height: 6, borderRadius: "50%", background: "#b37feb", animation: `wcTyping 1.2s infinite ease-in-out ${delay}s` }} />)}</div></div>;
export function Typing({ accent, authorName }: { accent: string; authorName: string }) {
return (
<div style={{ marginBottom: 6 }}>
<AuthorRow accent={accent} name={authorName} operator={false} />
<div style={{ display: "inline-flex", alignItems: "center", gap: 7, background: "#fff", border: "1px solid #eee", borderRadius: "4px 16px 16px 16px", padding: "11px 15px" }}>
<span style={{ fontSize: 12.5, color: "#8c8c8c" }}>{t("chat.typing")}</span>
<span style={{ display: "flex", alignItems: "center", gap: 5 }}>
{[0, 0.2, 0.4].map((delay) => <span key={delay} style={{ width: 6, height: 6, borderRadius: "50%", background: accent, animation: `wcTyping 1.2s infinite ease-in-out ${delay}s` }} />)}
</span>
</div>
</div>
);
}
+3 -1
View File
@@ -48,7 +48,9 @@ export type CallStateEnvelope = { call: CallInfo; iceServers: RTCIceServer[] };
// reset — диалог, который показывал виджет, удалили в рабочем месте: ленту
// надо начать с чистого листа, иначе клиент видит переписку, которой нет.
export type Poll = { state: "ai" | "operator" | "waiting"; lifecycle: string; messages: WebMessage[]; call?: CallInfo | null; reset?: boolean };
// thinking — агент уже считает ответ: он приходит не в том же запросе, что
// вопрос, и без этого признака клиент не понимал бы, ждать ему или нет.
export type Poll = { state: "ai" | "operator" | "waiting"; lifecycle: string; messages: WebMessage[]; call?: CallInfo | null; reset?: boolean; thinking?: boolean };
export type WidgetEntry = { widgetKey?: string; channel?: string };
+4 -1
View File
@@ -14,7 +14,6 @@ export const en: Record<ChatMessageKey, Message> = {
"chat.consent_revision": "rev. {version}",
"chat.invites_you_to_call": "{name} is inviting you to an online call",
"chat.download": "Download",
"chat.delivered": "delivered",
"chat.file_too_big": "The file is over 20 MB",
"chat.file_unavailable": "unavailable",
"chat.incoming_audio_call": "Incoming audio call",
@@ -40,6 +39,10 @@ export const en: Record<ChatMessageKey, Message> = {
"chat.temporarily_unavailable": "Temporarily unavailable",
"chat.unavailable_body": "Write to us on another channel — we will answer as soon as we can.",
"chat.unavailable_title": "The chat is temporarily unavailable",
"chat.powered_by": "Powered by",
"chat.expand_panel": "Expand the window",
"chat.shrink_panel": "Shrink the window",
"chat.typing": "typing",
"chat.virtual_assistant": "Virtual assistant",
"chat.voice_message": "Voice message",
"chat.operator_initial": "O",
+4 -1
View File
@@ -15,7 +15,6 @@ export const ru = {
"chat.consent_revision": "ред. {version}",
"chat.invites_you_to_call": "{name} приглашает вас на онлайн-звонок",
"chat.download": "Скачать",
"chat.delivered": "доставлено",
"chat.file_too_big": "Файл больше 20 МБ",
"chat.file_unavailable": "недоступен",
"chat.incoming_audio_call": "Входящий аудиозвонок",
@@ -41,6 +40,10 @@ export const ru = {
"chat.temporarily_unavailable": "Временно недоступен",
"chat.unavailable_body": "Напишите нам в другом канале — ответим как можно скорее.",
"chat.unavailable_title": "Чат временно недоступен",
"chat.powered_by": "Работает на",
"chat.expand_panel": "Развернуть окно",
"chat.shrink_panel": "Свернуть окно",
"chat.typing": "печатает",
"chat.virtual_assistant": "Виртуальный помощник",
"chat.voice_message": "Голосовое сообщение",
"chat.operator_initial": "О",
+30
View File
@@ -33,6 +33,36 @@ body,
40% { opacity: 1; transform: translateY(-2px); }
}
/* Кнопки виджета: отклик на наведение и нажатие в любом состоянии. */
.wc-icon-button {
transition: box-shadow 0.16s ease, transform 0.12s ease;
}
.wc-icon-button:hover {
box-shadow: inset 0 0 0 999px rgba(0, 0, 0, 0.07);
}
.wc-icon-button:active {
transform: scale(0.93);
}
.wc-icon-button:disabled {
box-shadow: none;
transform: none;
}
/* Смена знака на кнопке чата: лёгкий подскок. */
@keyframes wcLauncherPop {
from { opacity: 0; transform: scale(0.55) rotate(-25deg); }
to { opacity: 1; transform: none; }
}
/* Моргание знака агента: оба глаза одной группой, синхронно. */
@keyframes wcBotBlink {
0%, 92%, 100% { transform: scaleY(1); }
95% { transform: scaleY(0.12); }
}
/* Экран звонка (baseline «Экран звонка.dc.html») */
@keyframes edvRing {
0% { transform: scale(1); opacity: 0.55; }
+11
View File
@@ -116,6 +116,17 @@ services:
- ./apps/backend:/app/apps/backend
- ./data/media:/app/apps/backend/media
worker-events:
build:
context: .
dockerfile: apps/backend/Dockerfile
environment:
CHATBALLS_DEBUG: "true"
CHATBALLS_DELIVERY_MODE: ${CHATBALLS_DELIVERY_MODE:-CLOUD}
volumes:
- ./apps/backend:/app/apps/backend
- ./data/media:/app/apps/backend/media
# Dev: образ сервиса обновления собирается локально; сам сервис в dev
# бесполезен (стек поднят из исходников), но должен собираться и стартовать.
updater:
Loaded 100 of 106 files, more files were not shown because too many files have changed in this diff. Show more