mirror of
https://github.com/dartdavros/chatballs.git
synced 2026-10-05 09:14:58 +03:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f57b1ee54a | ||
|
|
64266dcc9c | ||
|
|
cce87a4973 | ||
|
|
556246865e | ||
|
|
0aff0c564b | ||
|
|
0d307c7d06 |
No files matched your search
@@ -31,18 +31,25 @@ from chatballs.ai.provider.base import (
|
||||
from chatballs.ai.provider.factory import get_provider
|
||||
from chatballs.ai.provider.resilience import CircuitBreaker, call_with_resilience
|
||||
|
||||
|
||||
# Предохранитель считает сбои по ключу «организация + интеграция»: провайдер у
|
||||
# каждой организации свой, и отозванный ключ одной не имеет отношения к AI
|
||||
# остальных. Общий на процесс предохранитель гасил AI у всех сразу.
|
||||
_breakers: dict[tuple[int, int], CircuitBreaker] = {}
|
||||
@dataclass(slots=True)
|
||||
class _BreakerSlot:
|
||||
revision: int
|
||||
breaker: CircuitBreaker
|
||||
|
||||
|
||||
def _breaker(key: tuple[int, int]) -> CircuitBreaker:
|
||||
breaker = _breakers.get(key)
|
||||
if breaker is None:
|
||||
breaker = CircuitBreaker()
|
||||
_breakers[key] = breaker
|
||||
return breaker
|
||||
_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:
|
||||
@@ -51,13 +58,14 @@ def reset_breakers() -> None:
|
||||
_breakers.clear()
|
||||
|
||||
|
||||
def _breaker_key(channel) -> tuple[int, int]:
|
||||
def _breaker_identity(channel) -> tuple[tuple[int, int], int]:
|
||||
"""Ключ предохранителя. Без канала провайдер может быть только тестовым —
|
||||
считать сбои там не по чему, и общий ключ (0, 0) никому не мешает."""
|
||||
|
||||
if channel is None:
|
||||
return (0, 0)
|
||||
return (channel.organization_id, routing.integration_id(channel))
|
||||
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:
|
||||
@@ -72,6 +80,7 @@ class ChatJob:
|
||||
model: str
|
||||
messages: list[ChatMessage]
|
||||
breaker_key: tuple[int, int]
|
||||
breaker_revision: int
|
||||
params: dict | None = None
|
||||
|
||||
|
||||
@@ -83,6 +92,7 @@ class EmbeddingJob:
|
||||
model: str
|
||||
texts: list[str]
|
||||
breaker_key: tuple[int, int]
|
||||
breaker_revision: int
|
||||
|
||||
|
||||
def _effective_model(channel, requested_model: str | None) -> str:
|
||||
@@ -109,11 +119,13 @@ def prepare_chat(
|
||||
) -> 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(channel),
|
||||
breaker_key=breaker_key,
|
||||
breaker_revision=breaker_revision,
|
||||
params=params,
|
||||
)
|
||||
|
||||
@@ -124,7 +136,7 @@ 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),
|
||||
breaker=_breaker(job.breaker_key, job.breaker_revision),
|
||||
)
|
||||
|
||||
|
||||
@@ -200,11 +212,13 @@ def prepare_embedding(
|
||||
) -> 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(channel),
|
||||
breaker_key=breaker_key,
|
||||
breaker_revision=breaker_revision,
|
||||
)
|
||||
|
||||
|
||||
@@ -214,7 +228,7 @@ 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),
|
||||
breaker=_breaker(job.breaker_key, job.breaker_revision),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -38,17 +38,11 @@ import urllib.error
|
||||
import urllib.request
|
||||
|
||||
from chatballs.ai.provider.base import (
|
||||
|
||||
ChatMessage,
|
||||
|
||||
ChatResult,
|
||||
|
||||
EmbeddingResult,
|
||||
|
||||
ProviderError,
|
||||
|
||||
ProviderRejected,
|
||||
|
||||
)
|
||||
from chatballs.i18n import t
|
||||
from chatballs.integrations.proxy import build_opener
|
||||
|
||||
@@ -38,10 +38,10 @@ 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:
|
||||
@@ -49,10 +49,10 @@ def call_with_resilience(
|
||||
# предохранитель тут ни при чём — сам провайдер жив и отвечает.
|
||||
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
|
||||
|
||||
@@ -136,6 +136,16 @@ def integration_id(channel) -> int:
|
||||
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:
|
||||
|
||||
@@ -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()
|
||||
@@ -9,5 +9,7 @@ class ConversationsConfig(AppConfig):
|
||||
|
||||
def ready(self) -> None:
|
||||
# Свежесть диалога поддерживает сигнал: сообщения создаются в семи местах.
|
||||
from chatballs.conversations import signals # noqa: F401
|
||||
from chatballs.conversations import event_handlers # noqa: F401 (register outbox handlers)
|
||||
from chatballs.conversations import (
|
||||
event_handlers, # noqa: F401 (register outbox handlers)
|
||||
signals, # noqa: F401
|
||||
)
|
||||
@@ -7,6 +7,8 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
from django.db.models import Count, Q
|
||||
from django.utils import timezone
|
||||
from rest_framework.request import Request
|
||||
@@ -38,6 +40,26 @@ def _label_payload(label: ConversationLabel) -> dict[str, object]:
|
||||
return {"id": label.id, "name": label.name, "color": label.color}
|
||||
|
||||
|
||||
# Переменные шаблонов ответов: подставляет их интерфейс оператора при вставке
|
||||
# шаблона (internal-ui, conversations/templateVariables.ts — тот же список).
|
||||
TEMPLATE_VARIABLES = frozenset({"client_name", "operator_name", "company"})
|
||||
_TEMPLATE_TOKEN = re.compile(r"\{\{\s*(\w+)\s*\}\}")
|
||||
|
||||
|
||||
def _template_text_error(text: str) -> Response | None:
|
||||
if not text:
|
||||
return Response({"detail": t("conversations.template_text_required")}, status=400)
|
||||
unknown = sorted(
|
||||
{match.group(0) for match in _TEMPLATE_TOKEN.finditer(text) if match.group(1) not in TEMPLATE_VARIABLES}
|
||||
)
|
||||
if unknown:
|
||||
return Response(
|
||||
{"detail": t("conversations.template_unknown_variables", names=", ".join(unknown))},
|
||||
status=400,
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def _template_payload(template: ReplyTemplate) -> dict[str, object]:
|
||||
return {
|
||||
"id": template.id,
|
||||
@@ -443,8 +465,8 @@ class ReplyTemplateListView(APIView):
|
||||
text = str(request.data.get("text", "")).strip()
|
||||
if not title or len(title) > 120:
|
||||
return Response({"detail": t("conversations.template_name_length")}, status=400)
|
||||
if not text:
|
||||
return Response({"detail": t("conversations.template_text_required")}, status=400)
|
||||
if error := _template_text_error(text):
|
||||
return error
|
||||
if ReplyTemplate.objects.filter(
|
||||
organization_id=request.tenant_context.organization_id, title__iexact=title
|
||||
).exists():
|
||||
@@ -475,11 +497,15 @@ class ReplyTemplateDetailView(APIView):
|
||||
title = str(request.data.get("title", "")).strip()
|
||||
if not title or len(title) > 120:
|
||||
return Response({"detail": t("conversations.template_name_length")}, status=400)
|
||||
if ReplyTemplate.objects.filter(
|
||||
organization_id=template.organization_id, title__iexact=title
|
||||
).exclude(id=template.id).exists():
|
||||
return Response({"detail": t("conversations.template_name_taken")}, status=409)
|
||||
template.title = title
|
||||
if "text" in request.data:
|
||||
text = str(request.data.get("text", "")).strip()
|
||||
if not text:
|
||||
return Response({"detail": t("conversations.template_text_required")}, status=400)
|
||||
if error := _template_text_error(text):
|
||||
return error
|
||||
template.text = text
|
||||
template.save()
|
||||
return Response({"template": _template_payload(template)})
|
||||
|
||||
@@ -157,15 +157,28 @@ def _history_item(conversation: Conversation) -> dict[str, object]:
|
||||
}
|
||||
|
||||
|
||||
def _contact_username(conversation: Conversation) -> str:
|
||||
# Username живёт на identity подключения (у контакта их может быть несколько).
|
||||
# Только в detail-режиме — в списках это лишний запрос на каждый диалог.
|
||||
def _connection_identity(conversation: Conversation) -> ConnectionIdentity | None:
|
||||
# Username и подпись гостя живут на identity подключения (у контакта их
|
||||
# может быть несколько). Только в detail-режиме — в списках это лишний
|
||||
# запрос на каждый диалог.
|
||||
if not conversation.connection_id:
|
||||
return ""
|
||||
identity = ConnectionIdentity.objects.filter(
|
||||
return None
|
||||
return ConnectionIdentity.objects.filter(
|
||||
connection_id=conversation.connection_id, contact_id=conversation.contact_id
|
||||
).first()
|
||||
return identity.username if identity else ""
|
||||
|
||||
|
||||
def _contact_is_guest(contact, identity: ConnectionIdentity | None) -> bool:
|
||||
# Гость виджета получает имя «Гость · <код сессии>» на языке организации;
|
||||
# та же подпись записана в display_name его identity. Пока имя не сменили,
|
||||
# настоящего имени у контакта нет — подставлять его в ответ нельзя.
|
||||
if not contact.name:
|
||||
return True
|
||||
return bool(
|
||||
identity
|
||||
and identity.display_name == contact.name
|
||||
and identity.external_user_id[:6] in contact.name
|
||||
)
|
||||
|
||||
|
||||
def _contact_email(conversation: Conversation) -> str:
|
||||
@@ -206,6 +219,7 @@ def conversation_payload(
|
||||
# pending_counts_for); поштучный расчёт остаётся для одиночных ответов.
|
||||
last = None if detailed else (last_message or _last_message(conversation))
|
||||
channel = conversation.channel
|
||||
identity = _connection_identity(conversation) if detailed and conversation.contact_id else None
|
||||
payload = {
|
||||
"id": conversation.id,
|
||||
"channel": {
|
||||
@@ -236,7 +250,9 @@ def conversation_payload(
|
||||
"company": conversation.contact.company,
|
||||
"city": conversation.contact.city,
|
||||
"email": _contact_email(conversation),
|
||||
"username": _contact_username(conversation) if detailed else "",
|
||||
"username": identity.username if identity else "",
|
||||
# Для переменной {{client_name}} шаблонов ответов.
|
||||
**({"isGuest": _contact_is_guest(conversation.contact, identity)} if detailed else {}),
|
||||
}
|
||||
if conversation.contact_id
|
||||
else None
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import json
|
||||
|
||||
from django.test import TestCase
|
||||
from django.test import SimpleTestCase, TestCase
|
||||
from django.utils import timezone
|
||||
|
||||
from chatballs.channels.models import Channel
|
||||
from chatballs.conversations.models import (
|
||||
ConnectionIdentity,
|
||||
Contact,
|
||||
ControlMode,
|
||||
Conversation,
|
||||
@@ -13,6 +14,7 @@ from chatballs.conversations.models import (
|
||||
LifecycleState,
|
||||
ReplyTemplate,
|
||||
)
|
||||
from chatballs.conversations.serializers import _contact_is_guest
|
||||
from chatballs.identity.group_models import EmployeeGroup, EmployeeGroupMember
|
||||
from chatballs.identity.models import (
|
||||
EmployeeRole,
|
||||
@@ -339,6 +341,65 @@ class ReplyTemplateTests(ChatExtrasTestCase):
|
||||
self.assertEqual(deleted.status_code, 204)
|
||||
self.assertFalse(ReplyTemplate.objects.exists())
|
||||
|
||||
def test_rename_to_taken_title_conflicts(self) -> None:
|
||||
ReplyTemplate.objects.create(
|
||||
organization_id=self.organization.id, title="Приветствие", text="Здравствуйте!"
|
||||
)
|
||||
other = ReplyTemplate.objects.create(
|
||||
organization_id=self.organization.id, title="Прощание", text="До свидания!"
|
||||
)
|
||||
|
||||
taken = self.admin_client.patch(
|
||||
f"/api/v1/conversations/templates/{other.id}/",
|
||||
data=json.dumps({"title": "приветствие"}),
|
||||
content_type="application/json",
|
||||
)
|
||||
self.assertEqual(taken.status_code, 409)
|
||||
other.refresh_from_db()
|
||||
self.assertEqual(other.title, "Прощание")
|
||||
|
||||
# Смена регистра собственного названия — не конфликт.
|
||||
recased = self.admin_client.patch(
|
||||
f"/api/v1/conversations/templates/{other.id}/",
|
||||
data=json.dumps({"title": "ПРОЩАНИЕ"}),
|
||||
content_type="application/json",
|
||||
)
|
||||
self.assertEqual(recased.status_code, 200)
|
||||
self.assertEqual(recased.json()["template"]["title"], "ПРОЩАНИЕ")
|
||||
|
||||
def test_template_variables_are_checked(self) -> None:
|
||||
created = self.admin_client.post(
|
||||
"/api/v1/conversations/templates/",
|
||||
data=json.dumps({"title": "Приветствие", "text": "Здравствуйте, {{client_name}}! Я {{ operator_name }}, {дата}."}),
|
||||
content_type="application/json",
|
||||
)
|
||||
self.assertEqual(created.status_code, 201)
|
||||
|
||||
typo = self.admin_client.post(
|
||||
"/api/v1/conversations/templates/",
|
||||
data=json.dumps({"title": "Опечатка", "text": "Здравствуйте, {{clent_name}}!"}),
|
||||
content_type="application/json",
|
||||
)
|
||||
self.assertEqual(typo.status_code, 400)
|
||||
self.assertIn("{{clent_name}}", typo.json()["detail"])
|
||||
|
||||
patched = self.admin_client.patch(
|
||||
f"/api/v1/conversations/templates/{created.json()['template']['id']}/",
|
||||
data=json.dumps({"text": "{{order_id}}"}),
|
||||
content_type="application/json",
|
||||
)
|
||||
self.assertEqual(patched.status_code, 400)
|
||||
|
||||
|
||||
class ContactGuestTests(SimpleTestCase):
|
||||
def test_widget_guest_label_is_not_a_name(self) -> None:
|
||||
identity = ConnectionIdentity(external_user_id="3ffa7b0c9d", display_name="Гость · 3ffa7b")
|
||||
self.assertTrue(_contact_is_guest(Contact(name="Гость · 3ffa7b"), identity))
|
||||
self.assertTrue(_contact_is_guest(Contact(name=""), None))
|
||||
# Гость представился — имя уже настоящее.
|
||||
self.assertFalse(_contact_is_guest(Contact(name="Дмитрий Орлов"), identity))
|
||||
self.assertFalse(_contact_is_guest(Contact(name="Иван"), None))
|
||||
|
||||
|
||||
class OnboardingTests(TestCase):
|
||||
def setUp(self) -> None:
|
||||
|
||||
@@ -19,7 +19,6 @@ from chatballs.conversations.transports.errors import PollFailed
|
||||
from chatballs.identity.bootstrap import bootstrap_owner
|
||||
from chatballs.identity.models import Organization
|
||||
from chatballs.integrations.models import Integration, IntegrationKind, IntegrationProvider
|
||||
|
||||
from chatballs.testing import ai_answer, run_pending_ai_turns
|
||||
|
||||
EMAIL_CONFIG = {
|
||||
|
||||
@@ -22,7 +22,8 @@ from chatballs.integrations.models import (
|
||||
IntegrationKind,
|
||||
IntegrationProvider,
|
||||
)
|
||||
from chatballs.testing import TenantAPIClient as APIClient, run_pending_ai_turns
|
||||
from chatballs.testing import TenantAPIClient as APIClient
|
||||
from chatballs.testing import run_pending_ai_turns
|
||||
|
||||
|
||||
def _connection(channel: Channel) -> Integration:
|
||||
|
||||
@@ -9,6 +9,7 @@ from django.test import TestCase
|
||||
from chatballs.ai.models import AIAgent, AIAgentStatus
|
||||
from chatballs.ai.provider.base import ProviderError
|
||||
from chatballs.channels.models import Channel
|
||||
from chatballs.conversations import ai_turn
|
||||
from chatballs.conversations.ingest import ingest_inbound
|
||||
from chatballs.conversations.models import ControlMode, MessageAuthor, MessageKind, TranscriptStatus
|
||||
from chatballs.conversations.transports.base import InboundMessage
|
||||
@@ -16,8 +17,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.conversations import ai_turn
|
||||
from chatballs.testing import TenantAPIClient as APIClient, ai_answer, run_pending_ai_turns
|
||||
from chatballs.testing import TenantAPIClient as APIClient
|
||||
from chatballs.testing import ai_answer, run_pending_ai_turns
|
||||
|
||||
|
||||
class VoiceAiReplyTests(TestCase):
|
||||
|
||||
@@ -27,8 +27,8 @@ 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
|
||||
from chatballs.testing import ai_answer, ai_failure, run_pending_ai_turns
|
||||
|
||||
|
||||
def _messenger_connection(channel):
|
||||
|
||||
@@ -12,7 +12,6 @@ from chatballs.identity.models import OrganizationMembership
|
||||
from chatballs.tenancy.context import TenantActorKind, TenantContext
|
||||
from chatballs.tenancy.lookup import load_organization
|
||||
|
||||
|
||||
# Где живёт outbox. Захват идёт по всем организациям сразу, поэтому читает и
|
||||
# отмечает события роль platform, а не app (chatballs.tenancy.routing).
|
||||
OUTBOX_DB = "platform"
|
||||
|
||||
@@ -76,6 +76,7 @@ MESSAGES: dict[str, object] = {
|
||||
"conversations.template_name_taken": "A template with this name already exists",
|
||||
"conversations.template_not_found": "Template not found",
|
||||
"conversations.template_text_required": "The template text is required",
|
||||
"conversations.template_unknown_variables": "Unknown variables: {names}. Use the “Insert a variable” button to add variables.",
|
||||
"conversations.unknown_label": "Unknown label",
|
||||
"conversations.unknown_priority": "Unknown priority",
|
||||
"conversations.voice_off_entry_point": "Voice messages are switched off for this entry point",
|
||||
@@ -494,6 +495,7 @@ MESSAGES: dict[str, object] = {
|
||||
"settings.email_hosts_required": "Email address, IMAP host and SMTP host are required",
|
||||
"settings.email_not_a_bot": "Email cannot be a notifications bot",
|
||||
"settings.inactive_channel": "Inactive channel cannot accept connections",
|
||||
"settings.integration_in_use": "The integration is used by an agent or channel. Select another integration for them first.",
|
||||
"settings.integration_other_organization": "Integration belongs to another organization",
|
||||
"settings.mailbox_password_required": "Mailbox password is required",
|
||||
"settings.name_required": "Name required",
|
||||
|
||||
@@ -80,6 +80,7 @@ MESSAGES: dict[str, object] = {
|
||||
"conversations.template_name_taken": "Шаблон с таким названием уже есть",
|
||||
"conversations.template_not_found": "Шаблон не найден",
|
||||
"conversations.template_text_required": "Текст шаблона обязателен",
|
||||
"conversations.template_unknown_variables": "Неизвестные переменные: {names}. Вставляйте переменные кнопкой «Вставить переменную».",
|
||||
"conversations.unknown_label": "Неизвестная метка",
|
||||
"conversations.unknown_priority": "Неизвестный приоритет",
|
||||
"conversations.voice_off_entry_point": "Голосовые отключены для этой точки входа",
|
||||
@@ -498,6 +499,7 @@ MESSAGES: dict[str, object] = {
|
||||
"settings.email_hosts_required": "Укажите адрес, IMAP- и SMTP-сервер",
|
||||
"settings.email_not_a_bot": "Почта не может быть ботом уведомлений",
|
||||
"settings.inactive_channel": "Неактивный канал не принимает подключения",
|
||||
"settings.integration_in_use": "Интеграция используется агентом или каналом. Сначала выберите для них другую интеграцию.",
|
||||
"settings.integration_other_organization": "Интеграция принадлежит другой организации",
|
||||
"settings.mailbox_password_required": "Укажите пароль ящика",
|
||||
"settings.name_required": "Укажите название",
|
||||
|
||||
@@ -189,7 +189,7 @@
|
||||
"replyTemplates": [
|
||||
{
|
||||
"title": "Greeting",
|
||||
"text": "Hello! My name is {name}, I am a consultant at Nord Atelier. Could you tell me the track width and the window drop — I will work out a quote."
|
||||
"text": "Hello! My name is {{operator_name}}, I am a consultant at Nord Atelier. Could you tell me the track width and the window drop — I will work out a quote."
|
||||
},
|
||||
{
|
||||
"title": "Lead times",
|
||||
|
||||
@@ -189,7 +189,7 @@
|
||||
"replyTemplates": [
|
||||
{
|
||||
"title": "Приветствие",
|
||||
"text": "Здравствуйте! Меня зовут {имя}, я консультант ателье «Норд». Подскажите, пожалуйста, ширину карниза и высоту окна — сделаю расчёт."
|
||||
"text": "Здравствуйте! Меня зовут {{operator_name}}, я консультант ателье «Норд». Подскажите, пожалуйста, ширину карниза и высоту окна — сделаю расчёт."
|
||||
},
|
||||
{
|
||||
"title": "Сроки пошива",
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.utils import timezone
|
||||
|
||||
from chatballs.i18n import t
|
||||
from chatballs.integrations import checks
|
||||
from chatballs.integrations.models import Integration, IntegrationProvider, IntegrationStatus
|
||||
from chatballs.integrations.runtime import advance_revision_after_successful_check
|
||||
from chatballs.tenancy.context import TenantContext
|
||||
|
||||
_CHECKS = {
|
||||
IntegrationProvider.OPENROUTER: checks.check_openrouter,
|
||||
IntegrationProvider.CUSTOM: checks.check_custom,
|
||||
IntegrationProvider.DEMO: checks.check_demo,
|
||||
IntegrationProvider.MAX: checks.check_max,
|
||||
IntegrationProvider.TELEGRAM: checks.check_telegram,
|
||||
IntegrationProvider.VK: checks.check_vk,
|
||||
}
|
||||
|
||||
|
||||
def _check_web(context: TenantContext, integration: Integration) -> tuple[bool, str, dict]:
|
||||
"""Проверить конфигурацию собственного Web-виджета без внешнего API."""
|
||||
|
||||
if integration.channel_id is None:
|
||||
return False, t("integrations.check_web_not_bound"), {}
|
||||
from chatballs.webchat.widgets import ensure_widget
|
||||
|
||||
try:
|
||||
widget = ensure_widget(integration)
|
||||
except ValidationError as error:
|
||||
return False, "; ".join(error.messages), {}
|
||||
if widget is None:
|
||||
return False, t("integrations.check_web_no_config"), {}
|
||||
if not widget.allowed_origins:
|
||||
return False, t("integrations.check_web_no_origins"), {}
|
||||
return True, t("integrations.check_web_active", channel=integration.channel.name), {}
|
||||
|
||||
|
||||
def test_integration(*, context: TenantContext, integration: Integration) -> Integration:
|
||||
if integration.organization_id != context.organization_id:
|
||||
raise ValidationError({"integration": t("settings.integration_other_organization")})
|
||||
if integration.provider == IntegrationProvider.WEB:
|
||||
ok, detail, meta = _check_web(context, integration)
|
||||
elif integration.provider == IntegrationProvider.EMAIL:
|
||||
ok, detail, meta = checks.check_email(
|
||||
secret=integration.secret,
|
||||
config=integration.config,
|
||||
)
|
||||
else:
|
||||
check = _CHECKS.get(integration.provider)
|
||||
if check is None:
|
||||
ok, detail, meta = False, t("integrations.check_unsupported"), {}
|
||||
else:
|
||||
ok, detail, meta = check(
|
||||
secret=integration.secret,
|
||||
base_url=str(integration.config.get("base_url", "")),
|
||||
proxy_url=str(integration.config.get("proxy_url", "")),
|
||||
)
|
||||
integration.status = IntegrationStatus.OK if ok else IntegrationStatus.ERROR
|
||||
integration.last_error = "" if ok else detail
|
||||
integration.last_checked_at = timezone.now()
|
||||
update_fields = ["status", "last_error", "last_checked_at", "updated_at"]
|
||||
if ok and advance_revision_after_successful_check(integration):
|
||||
# API и event-workers — разные процессы; ревизия инвалидирует их breaker.
|
||||
update_fields.append("runtime_revision")
|
||||
if ok and meta:
|
||||
config = {**integration.config}
|
||||
for key in ("bot_id", "bot_username", "bot_name"):
|
||||
if meta.get(key):
|
||||
config[key] = meta[key]
|
||||
if config != integration.config:
|
||||
integration.config = config
|
||||
update_fields.append("config")
|
||||
integration.save(update_fields=update_fields)
|
||||
if integration.provider == IntegrationProvider.WEB:
|
||||
from chatballs.webchat.widgets import sync_widget_check_status
|
||||
|
||||
sync_widget_check_status(integration, ok=ok)
|
||||
return integration
|
||||
@@ -0,0 +1,19 @@
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.db.models.deletion import ProtectedError
|
||||
|
||||
from chatballs.i18n import t
|
||||
from chatballs.integrations.models import Integration
|
||||
from chatballs.tenancy.context import TenantContext
|
||||
|
||||
|
||||
class IntegrationInUse(Exception):
|
||||
"""Интеграция связана с агентом или каналом и не может быть удалена."""
|
||||
|
||||
|
||||
def delete_integration(*, context: TenantContext, integration: Integration) -> None:
|
||||
if integration.organization_id != context.organization_id:
|
||||
raise ValidationError({"integration": t("settings.integration_other_organization")})
|
||||
try:
|
||||
integration.delete()
|
||||
except ProtectedError as error:
|
||||
raise IntegrationInUse(t("settings.integration_in_use")) from error
|
||||
@@ -0,0 +1,15 @@
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("integrations", "0009_integration_vk"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="integration",
|
||||
name="runtime_revision",
|
||||
field=models.PositiveBigIntegerField(default=1),
|
||||
),
|
||||
]
|
||||
@@ -69,6 +69,9 @@ class Integration(models.Model):
|
||||
poll_marker = models.CharField(max_length=64, blank=True)
|
||||
last_checked_at = models.DateTimeField(null=True, blank=True)
|
||||
last_error = models.TextField(blank=True)
|
||||
# Версия runtime-настроек LLM. Event-workers держат circuit breaker в своей
|
||||
# памяти и заменяют его после исправления конфигурации провайдера.
|
||||
runtime_revision = models.PositiveBigIntegerField(default=1)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
from chatballs.integrations.models import Integration, IntegrationKind
|
||||
|
||||
|
||||
def advance_revision_after_configuration_change(
|
||||
integration: Integration,
|
||||
*,
|
||||
previous_config: dict,
|
||||
previous_secret: str,
|
||||
) -> None:
|
||||
if integration.kind == IntegrationKind.LLM_PROVIDER and (
|
||||
integration.config != previous_config or integration.secret != previous_secret
|
||||
):
|
||||
integration.runtime_revision += 1
|
||||
|
||||
|
||||
def advance_revision_after_successful_check(integration: Integration) -> bool:
|
||||
if integration.kind != IntegrationKind.LLM_PROVIDER:
|
||||
return False
|
||||
integration.runtime_revision += 1
|
||||
return True
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -61,6 +61,7 @@ export function ChatPage({
|
||||
? <Hint id="chat-visibility">{t("admin.see_every_conversation_organization_operators")}</Hint>
|
||||
: undefined}
|
||||
viewerId={user.id}
|
||||
sender={{ operatorName: user.fullName, company: user.organizationName }}
|
||||
renderContextPanel={({ dialog, detail, applyConversation, startCall, closeContext, assignmentTimeoutMinutes }) => (
|
||||
<ChatContextPanel
|
||||
rightTab={rightTab}
|
||||
|
||||
@@ -1,24 +1,25 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
import { Icon, LogoSpinner } from "../../shared/icons";
|
||||
import { ComposerTemplatesMenu } from "./ComposerTemplatesMenu";
|
||||
import { EmojiPicker } from "./EmojiPicker";
|
||||
import { useMediaQuery } from "../../shared/useMediaQuery";
|
||||
import { fetchReplyTemplates, sendFileMessage, sendOperatorMessage, sendVoiceMessage, type ReplyTemplateRef } from "./model";
|
||||
import { sendFileMessage, sendOperatorMessage, sendVoiceMessage } from "./model";
|
||||
import { formatSize } from "../ai/knowledge/model";
|
||||
import { formatDuration } from "./VoiceMessage";
|
||||
import { TEMPLATE_VARIABLE_LABEL, unfilledVariables, type TemplateValues } from "./templateVariables";
|
||||
import { useComposerTemplates } from "./useComposerTemplates";
|
||||
import { useVoiceRecorder } from "./useVoiceRecorder";
|
||||
import type { ChannelKey, ControlMode } from "./types";
|
||||
import { t } from "../../i18n";
|
||||
|
||||
const MAX_FILE_BYTES = 20 * 1024 * 1024;
|
||||
|
||||
export function Composer({ mode, loaded, assignedOperatorName, conversationId, channel, voiceAllowed = true, onClaim, onRelease, onReturnQueue, onClose, onSent }: { mode: ControlMode; loaded: boolean; assignedOperatorName?: string; conversationId: number | null; channel?: ChannelKey; voiceAllowed?: boolean; onClaim: () => void; onRelease: () => void; onReturnQueue: () => void; onClose: () => void; onSent: () => void }) {
|
||||
export function Composer({ mode, loaded, assignedOperatorName, conversationId, channel, voiceAllowed = true, templateValues = {}, onClaim, onRelease, onReturnQueue, onClose, onSent }: { mode: ControlMode; loaded: boolean; assignedOperatorName?: string; conversationId: number | null; channel?: ChannelKey; voiceAllowed?: boolean; templateValues?: TemplateValues; onClaim: () => void; onRelease: () => void; onReturnQueue: () => void; onClose: () => void; onSent: () => void }) {
|
||||
const [text, setText] = useState("");
|
||||
const compact = useMediaQuery("(max-width: 768px)");
|
||||
const [sending, setSending] = useState(false);
|
||||
const [sendError, setSendError] = useState("");
|
||||
const [templates, setTemplates] = useState<ReplyTemplateRef[]>([]);
|
||||
const [templatesOpen, setTemplatesOpen] = useState(false);
|
||||
const [attachment, setAttachment] = useState<File | null>(null);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
@@ -35,25 +36,10 @@ export function Composer({ mode, loaded, assignedOperatorName, conversationId, c
|
||||
// Сброс черновика вложения при смене диалога.
|
||||
useEffect(() => { setAttachment(null); setSendError(""); }, [conversationId]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchReplyTemplates().then(setTemplates).catch(() => setTemplates([]));
|
||||
}, []);
|
||||
|
||||
// Шаблоны «/» (дизайн-базлайн v2 §9): ввод «/» в начале открывает список,
|
||||
// продолжение ввода фильтрует по названию.
|
||||
const slashQuery = text.startsWith("/") ? text.slice(1).trim().toLowerCase() : null;
|
||||
const visibleTemplates = useMemo(() => {
|
||||
if (templates.length === 0) return [];
|
||||
if (slashQuery === null) return templates;
|
||||
return templates.filter((template) => template.title.toLowerCase().includes(slashQuery));
|
||||
}, [templates, slashQuery]);
|
||||
const menuOpen = templatesOpen || (slashQuery !== null && visibleTemplates.length > 0);
|
||||
|
||||
function applyTemplate(template: ReplyTemplateRef) {
|
||||
setText(template.text);
|
||||
setTemplatesOpen(false);
|
||||
textareaRef.current?.focus();
|
||||
}
|
||||
const templates = useComposerTemplates({ text, setText, values: templateValues, textareaRef });
|
||||
// Переменная шаблона без значения (у гостя нет имени) остаётся в тексте:
|
||||
// пока оператор её не заполнит, ответ не уходит.
|
||||
const unfilled = unfilledVariables(text);
|
||||
|
||||
// Запись голосового: во всех каналах (TG/MAX sendVoice, почта — вложением, Web — поллингом).
|
||||
const recorder = useVoiceRecorder({
|
||||
@@ -111,7 +97,7 @@ export function Composer({ mode, loaded, assignedOperatorName, conversationId, c
|
||||
|
||||
async function send() {
|
||||
const value = text.trim();
|
||||
if ((!value && !attachment) || conversationId == null || sending) return;
|
||||
if ((!value && !attachment) || conversationId == null || sending || unfilled.length > 0) return;
|
||||
setSending(true);
|
||||
setSendError("");
|
||||
try {
|
||||
@@ -163,7 +149,9 @@ export function Composer({ mode, loaded, assignedOperatorName, conversationId, c
|
||||
|
||||
// Кадры A–C: композер активен всегда (решение 2) — первое сообщение
|
||||
// перехватывает диалог; над полем одна строка-предупреждение.
|
||||
const warning = mode === "ai"
|
||||
const warning = unfilled.length > 0
|
||||
? { color: "var(--warning-text)", dot: "var(--warning)", text: t("conversations.fill_template_variables", { names: unfilled.map((name) => t(TEMPLATE_VARIABLE_LABEL[name]).toLocaleLowerCase()).join(", ") }) }
|
||||
: mode === "ai"
|
||||
? { color: "var(--ai)", text: t("conversations.ai_handling_conversation_message_takes") }
|
||||
: mode === "waiting"
|
||||
? { color: "var(--warning-text)", dot: "var(--warning)", text: t("conversations.customer_waiting_message_assigns_conversation") }
|
||||
@@ -174,17 +162,7 @@ export function Composer({ mode, loaded, assignedOperatorName, conversationId, c
|
||||
<div className="composer-wrap">
|
||||
{warning && <div className="composer-warning" style={{ color: warning.color }}><i style={{ background: warning.dot ?? warning.color }} />{warning.text}</div>}
|
||||
<div className="composer-box">
|
||||
{menuOpen && (
|
||||
<div className="composer-templates-menu">
|
||||
{visibleTemplates.length === 0 && <p>{t("conversations.no_matching_templates")}</p>}
|
||||
{visibleTemplates.map((template) => (
|
||||
<button key={template.id} type="button" onMouseDown={(event) => { event.preventDefault(); applyTemplate(template); }}>
|
||||
<strong>{template.title}</strong>
|
||||
<small>{template.text.replace(/\s+/g, " ").slice(0, 80)}</small>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{templates.menuOpen && <ComposerTemplatesMenu items={templates.visible} onPick={templates.apply} />}
|
||||
{attachment && (
|
||||
<div className="composer-attachment">
|
||||
<Icon name="paperclip" size={14} />
|
||||
@@ -202,14 +180,14 @@ export function Composer({ mode, loaded, assignedOperatorName, conversationId, c
|
||||
value={text}
|
||||
onChange={(event) => setText(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Escape" && menuOpen) { setTemplatesOpen(false); if (slashQuery !== null) setText(""); return; }
|
||||
if (event.key === "Escape" && templates.menuOpen) { templates.close(); if (templates.slashQuery !== null) setText(""); return; }
|
||||
if (event.key === "Enter" && !event.shiftKey) {
|
||||
if (slashQuery !== null && visibleTemplates.length > 0) { event.preventDefault(); applyTemplate(visibleTemplates[0]); return; }
|
||||
if (templates.slashQuery !== null && templates.visible.length > 0) { event.preventDefault(); templates.apply(templates.visible[0]); return; }
|
||||
event.preventDefault();
|
||||
void send();
|
||||
}
|
||||
}}
|
||||
onBlur={() => setTemplatesOpen(false)}
|
||||
onBlur={templates.close}
|
||||
onPaste={(event) => {
|
||||
const file = Array.from(event.clipboardData?.files ?? [])[0];
|
||||
if (file) { event.preventDefault(); pickFile(file); }
|
||||
@@ -226,12 +204,12 @@ export function Composer({ mode, loaded, assignedOperatorName, conversationId, c
|
||||
<Icon name="mic" size={17} />
|
||||
</button>
|
||||
)}
|
||||
{templates.length > 0 && (
|
||||
<button className="composer-tool is-labeled" title={t("conversations.reply_templates")} type="button" onClick={() => setTemplatesOpen((open) => !open)}>
|
||||
{templates.available && (
|
||||
<button className="composer-tool is-labeled" title={t("conversations.reply_templates")} type="button" onClick={templates.toggle}>
|
||||
<Icon name="text" size={16} />{t("conversations.templates")}</button>
|
||||
)}
|
||||
<span className="composer-spacer" />
|
||||
<button className="composer-send" type="button" onClick={() => void send()} disabled={sending || (!text.trim() && !attachment)}><span>{t("conversations.send")}</span><kbd>⏎</kbd><Icon name="send" size={17} /></button>
|
||||
<button className="composer-send" type="button" onClick={() => void send()} disabled={sending || (!text.trim() && !attachment) || unfilled.length > 0}><span>{t("conversations.send")}</span><kbd>⏎</kbd><Icon name="send" size={17} /></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { ReplyTemplateRef } from "./model";
|
||||
import { t } from "../../i18n";
|
||||
|
||||
export function ComposerTemplatesMenu({ items, onPick }: { items: ReplyTemplateRef[]; onPick: (template: ReplyTemplateRef) => void }) {
|
||||
return (
|
||||
<div className="composer-templates-menu">
|
||||
{items.length === 0 && <p>{t("conversations.no_matching_templates")}</p>}
|
||||
{items.map((template) => (
|
||||
<button key={template.id} type="button" onMouseDown={(event) => { event.preventDefault(); onPick(template); }}>
|
||||
<strong>{template.title}</strong>
|
||||
<small>{template.text.replace(/\s+/g, " ").slice(0, 80)}</small>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -57,7 +57,7 @@ import { t } from "../../i18n";
|
||||
// backend по группам (ADR-CHATBALLS-0043); страница параметризуется заголовком,
|
||||
// placeholder поиска и правой панелью через render-prop. Список и история —
|
||||
// серверные окна: ни то, ни другое целиком не запрашивается.
|
||||
export function ConversationWorkspace({ isOwner = false, canDelete = false, viewerId = null, listTitle, searchPlaceholder, renderContextPanel, mobileHeader, hint, initialConversationId, scope, setScope, counters, showScopeSwitcher = true }: {
|
||||
export function ConversationWorkspace({ isOwner = false, canDelete = false, viewerId = null, listTitle, searchPlaceholder, renderContextPanel, mobileHeader, hint, initialConversationId, scope, setScope, counters, showScopeSwitcher = true, sender }: {
|
||||
isOwner?: boolean;
|
||||
/** Удалять диалоги могут владелец и администратор (то же проверяет сервер). */
|
||||
canDelete?: boolean;
|
||||
@@ -74,6 +74,8 @@ export function ConversationWorkspace({ isOwner = false, canDelete = false, view
|
||||
setScope: (scope: DialogScope) => void;
|
||||
counters: ConversationCounters | null;
|
||||
showScopeSwitcher?: boolean;
|
||||
/** Кто отвечает — для переменных шаблонов ответов. */
|
||||
sender?: { operatorName: string; company: string };
|
||||
}) {
|
||||
const [listTab, setListTab] = useState<ListTab>("all");
|
||||
const [sort, setSort] = useState<ListSort>("activity");
|
||||
@@ -289,6 +291,11 @@ export function ConversationWorkspace({ isOwner = false, canDelete = false, view
|
||||
mode={controlMode}
|
||||
channel={selectedDialog?.channel}
|
||||
voiceAllowed={detail?.connection?.voiceMessages ?? false}
|
||||
templateValues={{
|
||||
client_name: detail?.contact?.isGuest ? "" : detail?.contact?.name,
|
||||
operator_name: sender?.operatorName,
|
||||
company: sender?.company,
|
||||
}}
|
||||
loaded={detailLoaded}
|
||||
assignedOperatorName={detail?.assignedOperator?.name}
|
||||
conversationId={selectedId}
|
||||
|
||||
@@ -72,7 +72,8 @@ export type ApiConversation = {
|
||||
connection: { id: number; provider: "EMAIL" | "MAX" | "TELEGRAM" | "VK" | "WEB"; name: string; voiceMessages?: boolean; audioCalls?: boolean; videoCalls?: boolean } | null;
|
||||
// Контакт — единственный источник identity диалога.
|
||||
// phone появляется после явного шаринга контакта; username (@логин TG/MAX) — только в detail-режиме.
|
||||
contact: { id: number; name: string; email?: string; phone?: string; username?: string; avatarUrl?: string; description?: string; company?: string; city?: string } | null;
|
||||
// isGuest — только в detail-режиме: имя гостя виджета — подпись «Гость · код», а не имя.
|
||||
contact: { id: number; name: string; email?: string; phone?: string; username?: string; avatarUrl?: string; description?: string; company?: string; city?: string; isGuest?: boolean } | null;
|
||||
lifecycle: "OPEN" | "CLOSED" | "SPAM";
|
||||
controlMode: "AI" | "HUMAN" | "PAUSED";
|
||||
expectedResponder: string;
|
||||
@@ -371,6 +372,18 @@ export const createConversationLabel = (name: string, color = "") =>
|
||||
}).then((r) => r.label);
|
||||
export const fetchReplyTemplates = () =>
|
||||
api<{ items: ReplyTemplateRef[] }>("/api/v1/conversations/templates/").then((r) => r.items);
|
||||
export const createReplyTemplate = (title: string, text: string) =>
|
||||
api<{ template: ReplyTemplateRef }>("/api/v1/conversations/templates/", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ title, text }),
|
||||
}).then((r) => r.template);
|
||||
export const updateReplyTemplate = (id: number, title: string, text: string) =>
|
||||
api<{ template: ReplyTemplateRef }>(`/api/v1/conversations/templates/${id}/`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({ title, text }),
|
||||
}).then((r) => r.template);
|
||||
export const deleteReplyTemplate = (id: number) =>
|
||||
api<void>(`/api/v1/conversations/templates/${id}/`, { method: "DELETE" });
|
||||
|
||||
export const transcribeMessage = (messageId: number) =>
|
||||
api<{ message: ApiMessage }>(`/api/v1/conversations/messages/${messageId}/transcribe/`, { method: "POST" }).then((r) => r.message);
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { renderTemplate, unfilledVariables } from "./templateVariables";
|
||||
|
||||
describe("переменные шаблона ответа", () => {
|
||||
it("подставляет известные значения", () => {
|
||||
const text = renderTemplate("Здравствуйте, {{client_name}}! Меня зовут {{ operator_name }}, {{company}}.", {
|
||||
client_name: "Дмитрий",
|
||||
operator_name: "Анна",
|
||||
company: "Ателье Норд",
|
||||
});
|
||||
expect(text).toBe("Здравствуйте, Дмитрий! Меня зовут Анна, Ателье Норд.");
|
||||
});
|
||||
|
||||
it("оставляет переменную без значения и чужие скобки как есть", () => {
|
||||
const text = renderTemplate("Здравствуйте, {{client_name}}! {{unknown}}", { client_name: " ", company: "Ателье Норд" });
|
||||
expect(text).toBe("Здравствуйте, {{client_name}}! {{unknown}}");
|
||||
});
|
||||
|
||||
it("находит незаполненные переменные без повторов", () => {
|
||||
expect(unfilledVariables("{{client_name}}, {{client_name}} и {{company}}, {{unknown}}")).toEqual(["client_name", "company"]);
|
||||
expect(unfilledVariables("Готовый текст")).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
import type { MessageKey } from "../../i18n/ru";
|
||||
|
||||
// Переменные шаблонов ответов. В тексте шаблона хранится код, а не слово на
|
||||
// языке интерфейса: язык организации меняется, код — нет. Тот же список
|
||||
// проверяет сервер при сохранении (conversations/chat_extras_views.py).
|
||||
|
||||
export const TEMPLATE_VARIABLES = ["client_name", "operator_name", "company"] as const;
|
||||
|
||||
export type TemplateVariable = (typeof TEMPLATE_VARIABLES)[number];
|
||||
export type TemplateValues = Partial<Record<TemplateVariable, string>>;
|
||||
|
||||
export const TEMPLATE_VARIABLE_LABEL: Record<TemplateVariable, MessageKey> = {
|
||||
client_name: "conversations.variable_client_name",
|
||||
operator_name: "conversations.variable_operator_name",
|
||||
company: "conversations.variable_company",
|
||||
};
|
||||
|
||||
const TOKEN = /\{\{\s*(\w+)\s*\}\}/g;
|
||||
|
||||
export function variableToken(variable: TemplateVariable): string {
|
||||
return `{{${variable}}}`;
|
||||
}
|
||||
|
||||
function isVariable(name: string): name is TemplateVariable {
|
||||
return (TEMPLATE_VARIABLES as readonly string[]).includes(name);
|
||||
}
|
||||
|
||||
/** Подставляет известные значения. Переменная без значения (у гостя нет
|
||||
* имени) остаётся в тексте как есть — её заполнит оператор. */
|
||||
export function renderTemplate(text: string, values: TemplateValues): string {
|
||||
return text.replace(TOKEN, (token, name: string) => {
|
||||
const value = isVariable(name) ? values[name]?.trim() : "";
|
||||
return value || token;
|
||||
});
|
||||
}
|
||||
|
||||
/** Переменные, которые остались в тексте незаполненными, без повторов. */
|
||||
export function unfilledVariables(text: string): TemplateVariable[] {
|
||||
const found = [...text.matchAll(TOKEN)].map((match) => match[1]).filter(isVariable);
|
||||
return [...new Set(found)];
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { useEffect, useMemo, useState, type RefObject } from "react";
|
||||
|
||||
import { fetchReplyTemplates, type ReplyTemplateRef } from "./model";
|
||||
import { renderTemplate, type TemplateValues } from "./templateVariables";
|
||||
|
||||
// Шаблоны «/» (дизайн-базлайн v2 §9): ввод «/» в начале открывает список,
|
||||
// продолжение ввода фильтрует по названию. Выбранный шаблон заменяет текст
|
||||
// поля, переменные подставляются сразу — оператор видит готовый ответ.
|
||||
|
||||
export function useComposerTemplates({ text, setText, values, textareaRef }: {
|
||||
text: string;
|
||||
setText: (text: string) => void;
|
||||
values: TemplateValues;
|
||||
textareaRef: RefObject<HTMLTextAreaElement | null>;
|
||||
}) {
|
||||
const [templates, setTemplates] = useState<ReplyTemplateRef[]>([]);
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
fetchReplyTemplates().then(setTemplates).catch(() => setTemplates([]));
|
||||
}, []);
|
||||
|
||||
const slashQuery = text.startsWith("/") ? text.slice(1).trim().toLowerCase() : null;
|
||||
const visible = useMemo(() => {
|
||||
if (templates.length === 0) return [];
|
||||
if (slashQuery === null) return templates;
|
||||
return templates.filter((template) => template.title.toLowerCase().includes(slashQuery));
|
||||
}, [templates, slashQuery]);
|
||||
|
||||
function apply(template: ReplyTemplateRef) {
|
||||
setText(renderTemplate(template.text, values));
|
||||
setOpen(false);
|
||||
textareaRef.current?.focus();
|
||||
}
|
||||
|
||||
return {
|
||||
available: templates.length > 0,
|
||||
visible,
|
||||
slashQuery,
|
||||
menuOpen: open || (slashQuery !== null && visible.length > 0),
|
||||
toggle: () => setOpen((current) => !current),
|
||||
close: () => setOpen(false),
|
||||
apply,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { Modal } from "antd";
|
||||
import { useRef, useState } from "react";
|
||||
|
||||
import { FormField, TextAreaField } from "../../shared/form-controls";
|
||||
import { Button } from "../../shared/ui-controls";
|
||||
import { createReplyTemplate, updateReplyTemplate, type ReplyTemplateRef } from "../conversations/model";
|
||||
import { variableToken, type TemplateVariable } from "../conversations/templateVariables";
|
||||
import { TemplateVariableMenu } from "./TemplateVariableMenu";
|
||||
import { t } from "../../i18n";
|
||||
|
||||
// Окно шаблона ответа: название и текст. Та же форма, что у интеграций и
|
||||
// переименования группы; ошибки (занятое название, пустой текст) — от сервера.
|
||||
|
||||
export function ReplyTemplateForm({ initial, onClose, onSaved }: {
|
||||
initial: ReplyTemplateRef | null;
|
||||
onClose: () => void;
|
||||
onSaved: () => void;
|
||||
}) {
|
||||
const [title, setTitle] = useState(initial?.title ?? "");
|
||||
const [text, setText] = useState(initial?.text ?? "");
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const textRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
function insertVariable(variable: TemplateVariable) {
|
||||
const token = variableToken(variable);
|
||||
const area = textRef.current;
|
||||
const start = area?.selectionStart ?? text.length;
|
||||
const end = area?.selectionEnd ?? text.length;
|
||||
setText(text.slice(0, start) + token + text.slice(end));
|
||||
window.requestAnimationFrame(() => {
|
||||
if (!area) return;
|
||||
area.focus();
|
||||
area.setSelectionRange(start + token.length, start + token.length);
|
||||
});
|
||||
}
|
||||
|
||||
async function save() {
|
||||
setSaving(true);
|
||||
setError("");
|
||||
try {
|
||||
if (initial) await updateReplyTemplate(initial.id, title.trim(), text.trim());
|
||||
else await createReplyTemplate(title.trim(), text.trim());
|
||||
onSaved();
|
||||
} catch (caught) {
|
||||
setError(caught instanceof Error ? caught.message : t("settings.could_not_save_template"));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal open title={initial ? t("settings.edit_template") : t("settings.new_template")} onCancel={onClose} footer={null} destroyOnClose>
|
||||
<div className="integration-form">
|
||||
<FormField label={t("common.title")} value={title} onChange={setTitle} />
|
||||
<TextAreaField label={t("settings.template_text")} value={text} onChange={setText} inputRef={textRef} />
|
||||
<TemplateVariableMenu onPick={insertVariable} />
|
||||
{error && <div className="integration-form-error">{error}</div>}
|
||||
<div className="integration-form-actions">
|
||||
<Button variant="secondary" onClick={onClose}>{t("common.cancel")}</Button>
|
||||
<Button variant="primary" disabled={saving || !title.trim() || !text.trim()} onClick={() => void save()}>{t("common.save")}</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { Modal } from "antd";
|
||||
import { useState } from "react";
|
||||
|
||||
import { EmptyState } from "../../shared/ui";
|
||||
import { Icon } from "../../shared/icons";
|
||||
import { Button } from "../../shared/ui-controls";
|
||||
import { deleteReplyTemplate, type ReplyTemplateRef } from "../conversations/model";
|
||||
import { fmt, t } from "../../i18n";
|
||||
|
||||
// Раздел «Шаблоны ответов» (согласован по образцу кадра N2 «Группы», своего
|
||||
// кадра в базлайне нет): строка — название · начало текста · дата изменения ·
|
||||
// изменить/удалить. Создание — primary-кнопкой в шапке раздела, правка —
|
||||
// окном, как у интеграций. Без права на настройки список только читается.
|
||||
|
||||
export function ReplyTemplatesCard({ items, canManage, reload, onEdit }: {
|
||||
items: ReplyTemplateRef[];
|
||||
canManage: boolean;
|
||||
reload: () => void;
|
||||
onEdit: (template: ReplyTemplateRef) => void;
|
||||
}) {
|
||||
const [deleting, setDeleting] = useState<ReplyTemplateRef | null>(null);
|
||||
const [deleteError, setDeleteError] = useState("");
|
||||
|
||||
async function remove() {
|
||||
if (!deleting) return;
|
||||
setDeleteError("");
|
||||
try {
|
||||
await deleteReplyTemplate(deleting.id);
|
||||
setDeleting(null);
|
||||
reload();
|
||||
} catch (caught) {
|
||||
setDeleteError(caught instanceof Error ? caught.message : t("settings.could_not_delete"));
|
||||
}
|
||||
}
|
||||
|
||||
if (items.length === 0) return <EmptyState title={t("settings.no_templates_yet")} />;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="table-card settings-groups-card">
|
||||
{items.map((template) => (
|
||||
<div
|
||||
className={`settings-group-row settings-template-row ${canManage ? "is-clickable" : ""}`}
|
||||
key={template.id}
|
||||
onClick={canManage ? () => onEdit(template) : undefined}
|
||||
>
|
||||
<strong>{template.title}</strong>
|
||||
<span className="settings-template-preview">{template.text.replace(/\s+/g, " ")}</span>
|
||||
<small>{fmt.shortDate(template.updatedAt)}</small>
|
||||
{canManage && (
|
||||
<div className="settings-group-actions" onClick={(event) => event.stopPropagation()}>
|
||||
<button aria-label={t("common.edit")} title={t("common.edit")} type="button" onClick={() => onEdit(template)}><Icon name="edit" size={14} /></button>
|
||||
<button aria-label={t("common.delete")} title={t("common.delete")} className="is-danger" type="button" onClick={() => { setDeleteError(""); setDeleting(template); }}><Icon name="trash" size={14} /></button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{deleting && (
|
||||
<Modal open title={t("settings.delete_template")} onCancel={() => setDeleting(null)} footer={null} destroyOnClose>
|
||||
<div className="integration-form">
|
||||
<p>{t("settings.template_will_be_deleted", { name: deleting.title })}</p>
|
||||
{deleteError && <div className="integration-form-error">{deleteError}</div>}
|
||||
<div className="integration-form-actions">
|
||||
<Button variant="secondary" onClick={() => setDeleting(null)}>{t("common.cancel")}</Button>
|
||||
<Button variant="danger-outline" onClick={() => void remove()}>{t("common.delete")}</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -18,8 +18,12 @@ import { PlatformSettingsCard } from "./PlatformSettingsCard";
|
||||
import { StorageSettingsCard } from "./StorageSettingsCard";
|
||||
import { GroupsSettingsCard } from "./GroupsSettingsCard";
|
||||
import { QueuePolicyCard } from "./QueuePolicyCard";
|
||||
import { ReplyTemplateForm } from "./ReplyTemplateForm";
|
||||
import { ReplyTemplatesCard } from "./ReplyTemplatesCard";
|
||||
import { DEFAULT_SETTINGS_SECTION, visibleSettingsSections, type SettingsSectionKey } from "./sections";
|
||||
import { useIntegrations } from "./useIntegrations";
|
||||
import { useReplyTemplates } from "./useReplyTemplates";
|
||||
import type { ReplyTemplateRef } from "../conversations/model";
|
||||
import { useOnboarding } from "../onboarding/useOnboarding";
|
||||
import type { TourTarget } from "../onboarding/steps";
|
||||
import { t } from "../../i18n";
|
||||
@@ -50,6 +54,8 @@ export function SettingsPage({ user, onUserUpdated, reload, groups = [], section
|
||||
const manager = isManager(user);
|
||||
const integrations = useIntegrations(manager);
|
||||
const [form, setForm] = useState<{ kind: IntegrationKind; initial: Integration | null } | null>(null);
|
||||
const templates = useReplyTemplates(manager);
|
||||
const [templateForm, setTemplateForm] = useState<{ initial: ReplyTemplateRef | null } | null>(null);
|
||||
// Кадр M: на узком экране субменю и раздел — два отдельных экрана.
|
||||
const mobile = useMediaQuery("(max-width: 900px)");
|
||||
|
||||
@@ -60,6 +66,7 @@ export function SettingsPage({ user, onUserUpdated, reload, groups = [], section
|
||||
const connectionsFailed = connections.some((item) => item.isActive && item.status === "ERROR");
|
||||
const counts: Partial<Record<SettingsSectionKey, number>> = {
|
||||
groups: groups.length,
|
||||
templates: templates.items.length,
|
||||
ai: providers.length,
|
||||
integrations: connections.length,
|
||||
};
|
||||
@@ -121,7 +128,9 @@ export function SettingsPage({ user, onUserUpdated, reload, groups = [], section
|
||||
? <Button variant="primary" icon="plus" onClick={() => setForm({ kind: "LLM_PROVIDER", initial: null })}>{t("settings.add_provider")}</Button>
|
||||
: current?.key === "integrations"
|
||||
? <Button variant="primary" icon="plus" onClick={() => setForm({ kind: "MESSENGER", initial: null })}>{t("settings.add_connection")}</Button>
|
||||
: null;
|
||||
: current?.key === "templates" && canManageSettings(user)
|
||||
? <Button variant="primary" icon="plus" onClick={() => setTemplateForm({ initial: null })}>{t("settings.add_template")}</Button>
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div className="settings-layout">
|
||||
@@ -151,6 +160,8 @@ export function SettingsPage({ user, onUserUpdated, reload, groups = [], section
|
||||
providers={providers}
|
||||
connections={connections}
|
||||
onEditIntegration={(item) => setForm({ kind: item.kind, initial: item })}
|
||||
templates={templates}
|
||||
onEditTemplate={(item) => setTemplateForm({ initial: item })}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
@@ -163,11 +174,18 @@ export function SettingsPage({ user, onUserUpdated, reload, groups = [], section
|
||||
onSaved={() => { setForm(null); integrations.reload(); }}
|
||||
/>
|
||||
)}
|
||||
{templateForm && (
|
||||
<ReplyTemplateForm
|
||||
initial={templateForm.initial}
|
||||
onClose={() => setTemplateForm(null)}
|
||||
onSaved={() => { setTemplateForm(null); templates.reload(); }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SectionBody({ section, user, onUserUpdated, reload, groups, integrations, providers, connections, onEditIntegration }: {
|
||||
function SectionBody({ section, user, onUserUpdated, reload, groups, integrations, providers, connections, onEditIntegration, templates, onEditTemplate }: {
|
||||
section: SettingsSectionKey;
|
||||
user: SessionUser;
|
||||
onUserUpdated: (user: SessionUser) => void;
|
||||
@@ -177,6 +195,8 @@ function SectionBody({ section, user, onUserUpdated, reload, groups, integration
|
||||
providers: Integration[];
|
||||
connections: Integration[];
|
||||
onEditIntegration: (integration: Integration) => void;
|
||||
templates: ReturnType<typeof useReplyTemplates>;
|
||||
onEditTemplate: (template: ReplyTemplateRef) => void;
|
||||
}): ReactNode {
|
||||
if (section === "organization") return <OrganizationSection user={user} onUserUpdated={onUserUpdated} />;
|
||||
if (section === "groups") return <GroupsSettingsCard groups={groups} reload={reload} />;
|
||||
@@ -185,6 +205,11 @@ function SectionBody({ section, user, onUserUpdated, reload, groups, integration
|
||||
if (section === "platform") return <PlatformSettingsCard canManage={user.isInstanceAdmin} />;
|
||||
if (section === "storage") return <StorageSettingsCard canManage={user.isInstanceAdmin} />;
|
||||
if (section === "demo") return <DemoDataCard reload={reload} />;
|
||||
if (section === "templates") {
|
||||
if (templates.loading) return <LoadingState />;
|
||||
if (templates.failed) return <EmptyState title={t("settings.could_not_load_templates")} />;
|
||||
return <ReplyTemplatesCard items={templates.items} canManage={canManageSettings(user)} reload={templates.reload} onEdit={onEditTemplate} />;
|
||||
}
|
||||
if (integrations.loading) return <LoadingState />;
|
||||
if (integrations.failed) return <EmptyState title={t("settings.could_not_load_integrations")} />;
|
||||
return (
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { Dropdown } from "antd";
|
||||
|
||||
import { Icon } from "../../shared/icons";
|
||||
import { TEMPLATE_VARIABLES, TEMPLATE_VARIABLE_LABEL, type TemplateVariable } from "../conversations/templateVariables";
|
||||
import { t } from "../../i18n";
|
||||
|
||||
// «Вставить переменную» в окне шаблона ответа: коды переменных не нужно
|
||||
// помнить — пункт меню вставляет его в позицию курсора.
|
||||
|
||||
export function TemplateVariableMenu({ onPick }: { onPick: (variable: TemplateVariable) => void }) {
|
||||
const items = TEMPLATE_VARIABLES.map((variable) => ({
|
||||
key: variable,
|
||||
label: t(TEMPLATE_VARIABLE_LABEL[variable]),
|
||||
onClick: () => onPick(variable),
|
||||
}));
|
||||
return (
|
||||
<Dropdown menu={{ items }} trigger={["click"]} placement="bottomLeft" overlayClassName="app-dropdown">
|
||||
<button className="link has-icon template-variable-trigger" type="button">
|
||||
<Icon name="plus" size={13} strokeWidth={2.2} />{t("settings.insert_variable")}
|
||||
</button>
|
||||
</Dropdown>
|
||||
);
|
||||
}
|
||||
@@ -10,6 +10,7 @@ export type SettingsSectionKey =
|
||||
| "organization"
|
||||
| "groups"
|
||||
| "queue"
|
||||
| "templates"
|
||||
| "ai"
|
||||
| "integrations"
|
||||
| "communication"
|
||||
@@ -51,6 +52,14 @@ export const SETTINGS_SECTIONS: SettingsSection[] = [
|
||||
heading: t("settings.when_to_call_for_help"),
|
||||
lead: t("settings.queue_section_lead"),
|
||||
},
|
||||
{
|
||||
// Своего кадра в базлайне нет: раздел согласован по образцу «Групп» (N2).
|
||||
key: "templates",
|
||||
label: t("settings.reply_templates"),
|
||||
icon: "text",
|
||||
heading: t("settings.reply_templates"),
|
||||
lead: t("settings.reply_templates_lead"),
|
||||
},
|
||||
{
|
||||
key: "ai",
|
||||
label: t("settings.ai_provider"),
|
||||
|
||||
@@ -386,6 +386,36 @@
|
||||
margin: 10px 0 4px;
|
||||
}
|
||||
|
||||
/* --- Шаблоны ответов: строка «Групп», вместо состава — начало текста --- */
|
||||
|
||||
.settings-template-row.is-clickable {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.settings-template-row strong {
|
||||
flex: 0 1 auto;
|
||||
max-width: 40%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* «Вставить переменную» — ссылка под полем текста, не во всю ширину формы. */
|
||||
.template-variable-trigger {
|
||||
align-self: flex-start;
|
||||
margin-top: -6px;
|
||||
}
|
||||
|
||||
.settings-template-preview {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
color: var(--n-4);
|
||||
font-size: 12.5px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* --- N3/N4 Таблицы: шапка 11px капсом на подложке, строки 62px --- */
|
||||
|
||||
.integrations-section {
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
import { fetchReplyTemplates, type ReplyTemplateRef } from "../conversations/model";
|
||||
|
||||
// Шаблоны грузятся на весь экран «Настроек», как интеграции: субменю
|
||||
// показывает их число, а раздел — список.
|
||||
|
||||
export type ReplyTemplatesState = {
|
||||
items: ReplyTemplateRef[];
|
||||
loading: boolean;
|
||||
failed: boolean;
|
||||
reload: () => void;
|
||||
};
|
||||
|
||||
export function useReplyTemplates(enabled: boolean): ReplyTemplatesState {
|
||||
const [items, setItems] = useState<ReplyTemplateRef[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [failed, setFailed] = useState(false);
|
||||
|
||||
const reload = useCallback(() => {
|
||||
if (!enabled) return;
|
||||
setFailed(false);
|
||||
fetchReplyTemplates()
|
||||
.then(setItems)
|
||||
.catch(() => setFailed(true))
|
||||
.finally(() => setLoading(false));
|
||||
}, [enabled]);
|
||||
|
||||
useEffect(() => {
|
||||
reload();
|
||||
}, [reload]);
|
||||
|
||||
return { items, loading, failed, reload };
|
||||
}
|
||||
@@ -754,6 +754,10 @@ export const en: Record<MessageKey, Message> = {
|
||||
"conversations.started": "Started",
|
||||
"conversations.take_conversation": "Take the conversation",
|
||||
"conversations.templates": "Templates",
|
||||
"conversations.variable_client_name": "Customer name",
|
||||
"conversations.variable_operator_name": "Operator name",
|
||||
"conversations.variable_company": "Organization name",
|
||||
"conversations.fill_template_variables": "Fill in the text: {names}",
|
||||
"conversations.transcribe": "Transcribe",
|
||||
"conversations.transcribing": "Transcribing…",
|
||||
"conversations.type_message_shift_enter_line": "Type a message… Shift+Enter for a line break, “/” for a reply template",
|
||||
@@ -1474,6 +1478,18 @@ export const en: Record<MessageKey, Message> = {
|
||||
"settings.queue_restore_defaults": "Restore the usual delays",
|
||||
"settings.queue_policy_unavailable": "Could not load the delays",
|
||||
"settings.queue_delivery_warning": "Reminders arrive where the employee will see them: the browser and the linked messenger. If a group has neither, only the counter in the conversation list is left.",
|
||||
"settings.reply_templates": "Reply templates",
|
||||
"settings.reply_templates_lead": "Ready-made replies for operators. In a conversation, type “/” in the message field to insert one.",
|
||||
"settings.add_template": "Add a template",
|
||||
"settings.no_templates_yet": "No templates yet. Add the first one — operators will call it up with “/”.",
|
||||
"settings.could_not_load_templates": "Could not load the templates",
|
||||
"settings.new_template": "New template",
|
||||
"settings.edit_template": "Edit the template",
|
||||
"settings.template_text": "Text",
|
||||
"settings.insert_variable": "Insert a variable",
|
||||
"settings.could_not_save_template": "Could not save the template",
|
||||
"settings.delete_template": "Delete the template?",
|
||||
"settings.template_will_be_deleted": "The template “{name}” will be deleted — operators will no longer be able to insert it.",
|
||||
"settings.ai_provider": "AI provider",
|
||||
"settings.allowed_domains": "Allowed domains",
|
||||
"settings.answers_from_agent_s_knowledge": "Answers from the agent's knowledge with no external requests or keys. Answer quality is limited — for real work connect OpenRouter or a compatible provider.",
|
||||
|
||||
@@ -755,6 +755,10 @@ export const ru = {
|
||||
"conversations.started": "Начат",
|
||||
"conversations.take_conversation": "Взять диалог",
|
||||
"conversations.templates": "Шаблоны",
|
||||
"conversations.variable_client_name": "Имя клиента",
|
||||
"conversations.variable_operator_name": "Имя оператора",
|
||||
"conversations.variable_company": "Название организации",
|
||||
"conversations.fill_template_variables": "Заполните в тексте: {names}",
|
||||
"conversations.transcribe": "Расшифровать",
|
||||
"conversations.transcribing": "Расшифровываем…",
|
||||
"conversations.type_message_shift_enter_line": "Введите сообщение… Shift+Enter — перенос строки, «/» — шаблон ответа",
|
||||
@@ -1475,6 +1479,18 @@ export const ru = {
|
||||
"settings.queue_restore_defaults": "Вернуть обычные сроки",
|
||||
"settings.queue_policy_unavailable": "Не удалось загрузить сроки",
|
||||
"settings.queue_delivery_warning": "Напоминания приходят туда, где сотрудник их получит: в браузер и в привязанный мессенджер. Если у группы не настроен ни один способ, останется только счётчик в списке диалогов.",
|
||||
"settings.reply_templates": "Шаблоны ответов",
|
||||
"settings.reply_templates_lead": "Готовые ответы для операторов. В диалоге шаблон вставляется, если набрать «/» в поле сообщения.",
|
||||
"settings.add_template": "Добавить шаблон",
|
||||
"settings.no_templates_yet": "Шаблонов пока нет. Добавьте первый — операторы вызовут его через «/».",
|
||||
"settings.could_not_load_templates": "Не удалось загрузить шаблоны",
|
||||
"settings.new_template": "Новый шаблон",
|
||||
"settings.edit_template": "Изменить шаблон",
|
||||
"settings.template_text": "Текст",
|
||||
"settings.insert_variable": "Вставить переменную",
|
||||
"settings.could_not_save_template": "Не удалось сохранить шаблон",
|
||||
"settings.delete_template": "Удалить шаблон?",
|
||||
"settings.template_will_be_deleted": "Шаблон «{name}» будет удалён — операторы больше не смогут его вставить.",
|
||||
"settings.ai_provider": "AI-провайдер",
|
||||
"settings.allowed_domains": "Разрешённые домены",
|
||||
"settings.answers_from_agent_s_knowledge": "Отвечает по знаниям агента без внешних запросов и ключей. Качество ответов ограничено — для реальной работы подключите OpenRouter или совместимый провайдер.",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useRef, useState, type ReactNode } from "react";
|
||||
import { useRef, useState, type ReactNode, type RefObject } from "react";
|
||||
|
||||
import { Icon } from "./icons";
|
||||
import { SelectMenu } from "./ui-controls";
|
||||
@@ -121,12 +121,13 @@ export function SelectField({ adornment, disabled = false, invalid = false, labe
|
||||
);
|
||||
}
|
||||
|
||||
export function TextAreaField({ disabled = false, label, value, onChange }: { disabled?: boolean; label: string; value: string; onChange: (value: string) => void }) {
|
||||
/** `inputRef` — для вставки в позицию курсора (переменные шаблона ответа). */
|
||||
export function TextAreaField({ disabled = false, label, value, onChange, inputRef }: { disabled?: boolean; label: string; value: string; onChange: (value: string) => void; inputRef?: RefObject<HTMLTextAreaElement | null> }) {
|
||||
const className = ["readonly-field", "form-field", "wide", disabled ? "is-readonly is-disabled" : "is-editable"].join(" ");
|
||||
return (
|
||||
<label className={className}>
|
||||
<span>{label}</span>
|
||||
<textarea value={value} disabled={disabled} readOnly={disabled} onChange={(event) => onChange(event.target.value)} />
|
||||
<textarea ref={inputRef} value={value} disabled={disabled} readOnly={disabled} onChange={(event) => onChange(event.target.value)} />
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user