mirror of
https://github.com/dartdavros/chatballs.git
synced 2026-10-05 17:14:59 +03:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0aff0c564b | ||
|
|
0d307c7d06 | ||
|
|
4e56b10e6a | ||
|
|
bcb66fadbc | ||
|
|
2ffad1e787 | ||
|
|
c69cd1b436 | ||
|
|
1f46ed62bd | ||
|
|
83b1119d6b |
No files matched your search
@@ -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,140 @@ 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 +176,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 +264,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,19 @@ 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 +84,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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -10,3 +10,4 @@ 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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
@@ -20,6 +20,8 @@ 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 = {
|
||||
|
||||
"email": "support@example.com",
|
||||
@@ -505,14 +507,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:
|
||||
|
||||
@@ -22,7 +22,7 @@ from chatballs.integrations.models import (
|
||||
IntegrationKind,
|
||||
IntegrationProvider,
|
||||
)
|
||||
from chatballs.testing import TenantAPIClient as APIClient
|
||||
from chatballs.testing import TenantAPIClient as APIClient, run_pending_ai_turns
|
||||
|
||||
|
||||
def _connection(channel: Channel) -> Integration:
|
||||
@@ -71,33 +71,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 +123,7 @@ class OperatorOnlyIngestTests(TestCase):
|
||||
|
||||
)
|
||||
|
||||
ai_turn.assert_not_called()
|
||||
self.assertEqual(turns, 0)
|
||||
|
||||
send.assert_not_called()
|
||||
|
||||
@@ -179,7 +167,7 @@ class OperatorOnlyIngestTests(TestCase):
|
||||
|
||||
|
||||
|
||||
ai_turn, send = self._ingest_without_ai(
|
||||
turns, send = self._ingest_without_ai(
|
||||
|
||||
InboundMessage(
|
||||
|
||||
@@ -211,7 +199,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(
|
||||
|
||||
@@ -16,7 +16,8 @@ from chatballs.identity.bootstrap import bootstrap_owner
|
||||
from chatballs.identity.models import Organization
|
||||
from chatballs.integrations.models import Integration, IntegrationKind, IntegrationProvider
|
||||
from chatballs.tenancy.database import tenant_atomic
|
||||
from chatballs.testing import TenantAPIClient as APIClient
|
||||
from chatballs.conversations import ai_turn
|
||||
from chatballs.testing import TenantAPIClient as APIClient, ai_answer, run_pending_ai_turns
|
||||
|
||||
|
||||
class VoiceAiReplyTests(TestCase):
|
||||
@@ -30,43 +31,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 +86,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):
|
||||
|
||||
@@ -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,
|
||||
@@ -28,6 +27,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 ai_answer, ai_failure, run_pending_ai_turns
|
||||
from chatballs.testing import TenantAPIClient as APIClient
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -12,6 +13,11 @@ 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:
|
||||
aggregate_type: str
|
||||
@@ -92,24 +98,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",
|
||||
@@ -493,6 +494,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": "Сотрудник отменил приглашение на звонок",
|
||||
@@ -497,6 +498,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": "Укажите название",
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
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
|
||||
@@ -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()
|
||||
)
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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"))
|
||||
|
||||
# Срок жизни анонимной сессии виджета: отсчёт от последней активности, а не от
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 };
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "О",
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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:
|
||||
|
||||
+27
-1
@@ -200,10 +200,13 @@ services:
|
||||
redis:
|
||||
condition: service_healthy
|
||||
|
||||
# Опрос подключений и периодические работы. Строго один экземпляр: курсоры
|
||||
# мессенджеров и паузы после сбоя живут в памяти процесса, и второй опросчик
|
||||
# забирал бы те же обновления второй раз.
|
||||
worker:
|
||||
image: ${CHATBALLS_BACKEND_IMAGE:-chatballs-backend:dev}
|
||||
restart: unless-stopped
|
||||
command: ["python", "manage.py", "run_worker"]
|
||||
command: ["python", "manage.py", "run_worker", "--role=poller"]
|
||||
environment:
|
||||
CHATBALLS_DB_ROLE: app
|
||||
# Захват outbox идёт по всем организациям сразу — воркеру нужен алиас
|
||||
@@ -217,6 +220,29 @@ services:
|
||||
backend-app:
|
||||
condition: service_healthy
|
||||
|
||||
# Обработка событий: ответы AI, доставка уведомлений, приглашения на звонки.
|
||||
# Ход AI ждёт модель секунды и десятки секунд, поэтому эта работа вынесена из
|
||||
# опроса и масштабируется репликами — события разбираются с `skip_locked`, а
|
||||
# ходы одного диалога всё равно идут по очереди
|
||||
# (chatballs.events.services.claim_next_outbox_event). Реплик стоит держать
|
||||
# немного: они ходят к провайдеру одним ключом организации.
|
||||
worker-events:
|
||||
image: ${CHATBALLS_BACKEND_IMAGE:-chatballs-backend:dev}
|
||||
restart: unless-stopped
|
||||
command: ["python", "manage.py", "run_worker", "--role=events"]
|
||||
deploy:
|
||||
replicas: ${CHATBALLS_EVENT_WORKERS:-2}
|
||||
environment:
|
||||
CHATBALLS_DB_ROLE: app
|
||||
CHATBALLS_DB_PLATFORM_ALIAS: "1"
|
||||
volumes:
|
||||
- chatballs-secrets:/run/chatballs/secrets:ro
|
||||
- chatballs-secrets-platform:/run/chatballs/secrets/platform:ro
|
||||
- chatballs-media:/app/apps/backend/media
|
||||
depends_on:
|
||||
backend-app:
|
||||
condition: service_healthy
|
||||
|
||||
# Обновление по кнопке из интерфейса (ADR-CHATBALLS-0049). Единственный
|
||||
# сервис с доступом к docker.sock: забирает запрос из тома chatballs-updates,
|
||||
# проверяет, что это официальный релиз (адрес со страницы релизов, образы по
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 557 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 583 KiB |
@@ -1,11 +0,0 @@
|
||||
<svg width="1386" height="1386" viewBox="0 0 1386 1386" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_2_3)">
|
||||
<rect width="1386" height="1386" rx="300" fill="#1677FF"/>
|
||||
<path d="M691.947 175C743.896 176.004 775.161 202.368 793.419 230.289C802.544 244.243 808.421 258.585 812.018 270.347C815.61 282.096 816.938 291.306 816.938 295.001C816.938 309.524 816.93 372.653 744.438 407.813V460.002H904.938V540.99L904.45 541.002L744.445 544.989L749.937 882.48C749.937 882.489 749.939 882.5 749.94 882.514C749.944 882.549 749.949 882.602 749.955 882.672C749.968 882.812 749.988 883.021 750.016 883.295C750.071 883.842 750.157 884.648 750.278 885.681C750.521 887.746 750.905 890.716 751.471 894.334C752.601 901.571 754.457 911.395 757.356 921.745C760.257 932.098 764.196 942.963 769.489 952.29C774.619 961.33 780.998 968.885 788.895 973.156L789.664 973.56L789.699 973.577L789.731 973.601C793.751 976.524 799.835 978.199 806.771 979.04C813.693 979.879 821.395 979.88 828.599 979.506C835.8 979.132 842.489 978.384 847.379 977.729C849.823 977.401 851.817 977.097 853.199 976.875C853.89 976.764 854.428 976.673 854.793 976.61C854.975 976.579 855.115 976.555 855.208 976.538C855.255 976.53 855.29 976.524 855.313 976.52C855.323 976.518 855.33 976.516 855.336 976.515C855.345 976.512 855.36 976.509 855.379 976.505C855.418 976.495 855.477 976.481 855.556 976.461C855.713 976.421 855.948 976.36 856.258 976.276C856.878 976.109 857.797 975.851 858.986 975.491C861.365 974.771 864.827 973.639 869.151 971.999C877.801 968.719 889.899 963.401 903.684 955.262C931.141 939.048 965.281 911.632 992.18 866.791C981.29 863.394 967.013 860.713 955.359 858.873C949.428 857.937 944.183 857.218 940.421 856.734C938.54 856.492 937.03 856.309 935.99 856.187C935.471 856.125 935.069 856.079 934.797 856.048C934.661 856.032 934.557 856.02 934.487 856.013C934.453 856.009 934.427 856.006 934.409 856.004C934.401 856.003 934.394 856.002 934.39 856.002C934.388 856.002 934.386 856.002 934.385 856.002L934.384 856.001H934.383L933.401 855.894L934.069 855.166L1073.57 703.165L1074.13 702.559L1074.41 703.333C1090.68 748.154 1096.94 802.964 1099.12 846.542C1100.22 868.336 1100.3 887.331 1100.1 900.879C1100 907.653 1099.84 913.065 1099.7 916.784C1099.63 918.644 1099.56 920.081 1099.51 921.053C1099.49 921.539 1099.47 921.909 1099.46 922.157C1099.45 922.282 1099.45 922.376 1099.44 922.438C1099.44 922.47 1099.44 922.494 1099.44 922.51C1099.44 922.517 1099.44 922.523 1099.44 922.527C1099.44 922.529 1099.44 922.531 1099.44 922.532C1099.43 922.533 1099.39 922.531 1098.94 922.505L1099.44 922.534L1099.39 923.361L1098.68 922.933L1048.66 892.726C1003.9 993.99 945.911 1044.08 883.83 1083.43C821.626 1122.85 755.486 1151.41 694.283 1209.87L693.948 1210.19L693.604 1209.88C626.889 1149.91 559.114 1118.72 497.414 1078.36C435.832 1038.08 380.46 988.73 338.686 892.68C328.31 897.648 315.933 905.371 306.086 911.902C301.091 915.215 296.752 918.217 293.662 920.391C292.117 921.478 290.883 922.358 290.037 922.966C289.614 923.269 289.288 923.505 289.067 923.665C288.957 923.745 288.874 923.806 288.817 923.847C288.789 923.867 288.768 923.883 288.754 923.894C288.747 923.899 288.741 923.903 288.737 923.905C288.736 923.906 288.734 923.907 288.733 923.907L288.732 923.908L288.048 924.408L287.941 923.567L288.438 923.505C287.942 923.568 287.942 923.567 287.941 923.566C287.941 923.566 287.941 923.564 287.94 923.562C287.94 923.559 287.94 923.555 287.939 923.549C287.938 923.537 287.936 923.518 287.933 923.494C287.927 923.446 287.918 923.373 287.906 923.277C287.884 923.086 287.851 922.8 287.81 922.424C287.727 921.67 287.611 920.551 287.478 919.085C287.21 916.153 286.872 911.834 286.587 906.28C286.016 895.173 285.656 879.126 286.5 859.358C288.189 819.824 294.695 765.402 313.962 705.85L314.224 705.041L314.803 705.662L455.303 856.163L455.976 856.884L454.997 857.001L397.147 863.923C397.186 864.016 397.228 864.119 397.276 864.232C397.53 864.832 397.917 865.718 398.445 866.858C399.502 869.139 401.127 872.437 403.397 876.495C407.939 884.612 415.061 895.773 425.381 907.932C446.013 932.242 479.425 960.545 530.546 976.515C553.481 979.493 570.422 978.743 581.621 977.252C587.224 976.506 591.391 975.575 594.151 974.832C595.531 974.461 596.561 974.137 597.242 973.906C597.583 973.791 597.836 973.699 598.004 973.637C598.088 973.606 598.151 973.581 598.191 973.565C598.211 973.558 598.227 973.552 598.236 973.548C598.241 973.546 598.244 973.544 598.246 973.543C598.249 973.542 598.254 973.54 598.26 973.537C598.274 973.531 598.297 973.521 598.326 973.508C598.386 973.48 598.477 973.436 598.598 973.376C598.839 973.256 599.199 973.068 599.661 972.8C600.586 972.264 601.923 971.408 603.553 970.142C606.812 967.608 611.246 963.425 615.904 956.842C625.218 943.678 635.442 920.895 638.938 882.477L642.432 544.989L482.925 541.002L482.438 540.99V460.002H642.483C642.506 459.763 642.536 459.433 642.572 459.019C642.654 458.09 642.768 456.741 642.895 455.058C643.147 451.691 643.45 446.988 643.652 441.648C644.054 431.045 644.053 417.961 642.48 407.824C603.762 387.517 586.111 359.35 578.152 336.227C574.167 324.647 572.613 314.338 572.056 306.921C571.777 303.212 571.748 300.226 571.788 298.164C571.808 297.133 571.846 296.333 571.8Line truncated
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_2_3">
|
||||
<rect width="1386" height="1386" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 5.9 KiB |
Reference in new issue
Block a user