mirror of
https://github.com/dartdavros/chatballs.git
synced 2026-10-05 17:14:59 +03:00
Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
df9c6ad6fd | ||
|
|
8c618691f6 | ||
|
|
f57b1ee54a | ||
|
|
64266dcc9c | ||
|
|
cce87a4973 | ||
|
|
556246865e |
No files matched your search
@@ -31,6 +31,7 @@ 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 у всех сразу.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Голосовые отключены для этой точки входа",
|
||||
|
||||
@@ -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": "Сроки пошива",
|
||||
|
||||
@@ -7,7 +7,6 @@ from chatballs.integrations.models import Integration, IntegrationProvider, Inte
|
||||
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,
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
from django.core.exceptions import ValidationError
|
||||
|
||||
from chatballs.i18n import t
|
||||
from chatballs.support_portals.models import PortalArticle
|
||||
from chatballs.support_portals.statuses import ArticleStatus
|
||||
|
||||
|
||||
def apply_article_curation(article: PortalArticle, data: dict) -> None:
|
||||
if "sortOrder" in data:
|
||||
article.sort_order = int(data["sortOrder"])
|
||||
if "relatedArticleIds" not in data:
|
||||
return
|
||||
related_ids = data["relatedArticleIds"]
|
||||
if (
|
||||
not isinstance(related_ids, list)
|
||||
or len(related_ids) > 5
|
||||
or any(type(item) is not int for item in related_ids)
|
||||
or len(set(related_ids)) != len(related_ids)
|
||||
or article.id in related_ids
|
||||
):
|
||||
raise ValidationError({"relatedArticleIds": t("portals.invalid_article_data")})
|
||||
valid_count = PortalArticle.objects.filter(
|
||||
id__in=related_ids,
|
||||
portal=article.portal,
|
||||
locale=article.locale,
|
||||
status=ArticleStatus.PUBLISHED,
|
||||
published_revision__isnull=False,
|
||||
).count()
|
||||
if valid_count != len(related_ids):
|
||||
raise ValidationError({"relatedArticleIds": t("portals.invalid_article_data")})
|
||||
article.related_article_ids = related_ids
|
||||
@@ -9,6 +9,7 @@ from django.utils.text import slugify
|
||||
from chatballs.ai.indexing import reindex_portal_article
|
||||
from chatballs.conversations.transports.base import guess_content_type, safe_filename
|
||||
from chatballs.i18n import t
|
||||
from chatballs.support_portals.article_curation import apply_article_curation
|
||||
from chatballs.support_portals.content_markdown import normalize_file_links
|
||||
from chatballs.support_portals.models import (
|
||||
PortalArticle,
|
||||
@@ -104,6 +105,7 @@ def create_article(
|
||||
category=category,
|
||||
slug=str(data.get("slug", "")).strip().lower(),
|
||||
locale=str(data.get("locale", portal.default_locale)).strip().lower(),
|
||||
sort_order=1000,
|
||||
)
|
||||
article.full_clean()
|
||||
article.save()
|
||||
@@ -143,8 +145,11 @@ def update_article(
|
||||
)
|
||||
article.slug = str(data.get("slug", article.slug)).strip().lower()
|
||||
article.locale = str(data.get("locale", article.locale)).strip().lower()
|
||||
apply_article_curation(article, data)
|
||||
article.full_clean()
|
||||
article.save(update_fields=["category", "slug", "locale", "updated_at"])
|
||||
article.save(update_fields=[
|
||||
"category", "slug", "locale", "sort_order", "related_article_ids", "updated_at",
|
||||
])
|
||||
return article
|
||||
|
||||
|
||||
|
||||
Whitespace-only changes.
Whitespace-only changes.
@@ -0,0 +1,96 @@
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from django.core.management.base import BaseCommand, CommandError
|
||||
|
||||
from chatballs.support_portals.models import PortalArticle, SupportPortal
|
||||
from chatballs.tenancy.context import TenantContext
|
||||
from chatballs.tenancy.database import tenant_atomic
|
||||
from chatballs.tenancy.ingress import support_portal_route
|
||||
from chatballs.tenancy.lookup import load_organization
|
||||
|
||||
|
||||
def checked_changes(portal: SupportPortal, plan: dict) -> list[PortalArticle]:
|
||||
if plan.get("portal") != portal.slug:
|
||||
raise CommandError("Portal slug differs from the curation plan")
|
||||
locale = plan.get("locale")
|
||||
order = plan.get("order")
|
||||
related = plan.get("related")
|
||||
if not isinstance(locale, str) or not isinstance(order, dict) or not isinstance(related, dict):
|
||||
raise CommandError("Invalid curation plan")
|
||||
articles = list(
|
||||
portal.articles.filter(
|
||||
locale=locale, status="PUBLISHED", published_revision__isnull=False
|
||||
).select_related("category")
|
||||
)
|
||||
by_slug = {item.slug: item for item in articles}
|
||||
ordered = [slug for slugs in order.values() for slug in slugs]
|
||||
if (
|
||||
len(ordered) != len(by_slug)
|
||||
or len(set(ordered)) != len(ordered)
|
||||
or set(ordered) != set(by_slug)
|
||||
or set(related) != set(by_slug)
|
||||
):
|
||||
raise CommandError("Plan must cover every published article exactly once")
|
||||
for category_slug, slugs in order.items():
|
||||
if not isinstance(slugs, list) or any(
|
||||
by_slug[slug].category.slug != category_slug for slug in slugs
|
||||
):
|
||||
raise CommandError(f"Wrong article category in {category_slug}")
|
||||
for slug, targets in related.items():
|
||||
if (
|
||||
not isinstance(targets, list)
|
||||
or len(targets) > 5
|
||||
or len(set(targets)) != len(targets)
|
||||
or slug in targets
|
||||
or not set(targets) <= set(by_slug)
|
||||
):
|
||||
raise CommandError(f"Invalid related articles for {slug}")
|
||||
changed = []
|
||||
for slugs in order.values():
|
||||
for position, slug in enumerate(slugs, start=1):
|
||||
article = by_slug[slug]
|
||||
target_ids = [by_slug[target].id for target in related[slug]]
|
||||
if article.sort_order != position or article.related_article_ids != target_ids:
|
||||
article.sort_order = position
|
||||
article.related_article_ids = target_ids
|
||||
changed.append(article)
|
||||
return changed
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "Validate or apply an editorial order and related articles to a public portal"
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument("--host", required=True)
|
||||
parser.add_argument("--file", required=True, help="JSON plan path or - for stdin")
|
||||
parser.add_argument("--apply", action="store_true")
|
||||
|
||||
def handle(self, *args, **options):
|
||||
hostname = options["host"].strip().lower().rstrip(".")
|
||||
route = support_portal_route(hostname)
|
||||
organization = load_organization(route.organization_id) if route else None
|
||||
if organization is None:
|
||||
raise CommandError("Public portal host not found")
|
||||
source = sys.stdin.read() if options["file"] == "-" else Path(options["file"]).read_text(encoding="utf-8")
|
||||
try:
|
||||
plan = json.loads(source)
|
||||
except json.JSONDecodeError as error:
|
||||
raise CommandError("Invalid JSON plan") from error
|
||||
context = TenantContext.for_resource(organization)
|
||||
with tenant_atomic(context):
|
||||
portal = SupportPortal.objects.filter(
|
||||
id=route.resource_id,
|
||||
organization=organization,
|
||||
status="PUBLISHED",
|
||||
).first()
|
||||
if portal is None:
|
||||
raise CommandError("Published portal not found")
|
||||
changed = checked_changes(portal, plan)
|
||||
if options["apply"] and changed:
|
||||
PortalArticle.objects.bulk_update(
|
||||
changed, ["sort_order", "related_article_ids"]
|
||||
)
|
||||
action = "Updated" if options["apply"] else "Would update"
|
||||
self.stdout.write(f"{action} {len(changed)} articles")
|
||||
@@ -0,0 +1,20 @@
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("support_portals", "0011_hosted_domains_follow_installation"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="portalarticle",
|
||||
name="sort_order",
|
||||
field=models.PositiveIntegerField(default=1000),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="portalarticle",
|
||||
name="related_article_ids",
|
||||
field=models.JSONField(blank=True, default=list),
|
||||
),
|
||||
]
|
||||
@@ -153,6 +153,8 @@ class PortalArticle(TenantRelationModel):
|
||||
)
|
||||
slug = models.SlugField(max_length=96)
|
||||
locale = models.CharField(max_length=16, default="ru")
|
||||
sort_order = models.PositiveIntegerField(default=1000)
|
||||
related_article_ids = models.JSONField(default=list, blank=True)
|
||||
status = models.CharField(
|
||||
max_length=16,
|
||||
choices=ArticleStatus.choices,
|
||||
|
||||
@@ -58,7 +58,9 @@ class PublicPortalDetailView(PublicPortalView):
|
||||
return Response({"detail": t("portals.not_found")}, status=404)
|
||||
context, portal = resolved
|
||||
with tenant_atomic(context):
|
||||
counts = category_article_counts(portal, published_only=True)
|
||||
counts = category_article_counts(
|
||||
portal, published_only=True, locale=portal.default_locale
|
||||
)
|
||||
return Response(
|
||||
{
|
||||
"portal": public_portal_payload(portal),
|
||||
@@ -85,6 +87,7 @@ class PublicArticleListView(PublicPortalView):
|
||||
locale=locale,
|
||||
category=str(request.query_params.get("category", "")),
|
||||
query=str(request.query_params.get("q", "")).strip(),
|
||||
direct_category=request.query_params.get("direct") == "1",
|
||||
)
|
||||
try:
|
||||
limit = min(max(int(request.query_params.get("limit", 50)), 1), 100)
|
||||
@@ -122,7 +125,17 @@ class PublicArticleDetailView(PublicPortalView):
|
||||
).first()
|
||||
if article is None:
|
||||
return Response({"detail": t("portals.article_not_found")}, status=404)
|
||||
return Response({"article": public_article_payload(article)})
|
||||
payload = public_article_payload(article)
|
||||
related = public_articles(portal, locale=locale).filter(
|
||||
id__in=article.related_article_ids
|
||||
)
|
||||
by_id = {item.id: item for item in related}
|
||||
payload["relatedArticles"] = [
|
||||
public_article_payload(by_id[item_id], content=False)
|
||||
for item_id in article.related_article_ids
|
||||
if item_id in by_id
|
||||
]
|
||||
return Response({"article": payload})
|
||||
|
||||
|
||||
class PublicArticleFeedbackView(PublicPortalView):
|
||||
|
||||
@@ -108,6 +108,7 @@ def public_articles(
|
||||
locale: str,
|
||||
category: str = "",
|
||||
query: str = "",
|
||||
direct_category: bool = False,
|
||||
) -> QuerySet[PortalArticle]:
|
||||
articles = (
|
||||
PortalArticle.objects.filter(
|
||||
@@ -118,13 +119,13 @@ def public_articles(
|
||||
)
|
||||
.select_related("category", "published_revision")
|
||||
.prefetch_related("files")
|
||||
.order_by("category__sort_order", "published_revision__title")
|
||||
.order_by("category__sort_order", "sort_order", "published_revision__title", "id")
|
||||
)
|
||||
if category:
|
||||
root = portal.categories.filter(slug=category).first()
|
||||
if root is None:
|
||||
return articles.none()
|
||||
category_ids = descendant_category_ids(portal, root.id)
|
||||
category_ids = {root.id} if direct_category else descendant_category_ids(portal, root.id)
|
||||
articles = articles.filter(category_id__in=category_ids)
|
||||
if query:
|
||||
articles = articles.filter(
|
||||
@@ -151,10 +152,12 @@ def descendant_category_ids(portal: SupportPortal, root_id: int) -> set[int]:
|
||||
|
||||
|
||||
def category_article_counts(
|
||||
portal: SupportPortal, *, published_only: bool = False
|
||||
portal: SupportPortal, *, published_only: bool = False, locale: str | None = None
|
||||
) -> dict[int, int]:
|
||||
categories = list(portal.categories.all())
|
||||
direct_query = PortalArticle.objects.filter(portal=portal)
|
||||
if locale is not None:
|
||||
direct_query = direct_query.filter(locale=locale)
|
||||
if published_only:
|
||||
direct_query = direct_query.filter(
|
||||
status="PUBLISHED", published_revision__isnull=False
|
||||
|
||||
@@ -107,6 +107,8 @@ def article_payload(
|
||||
"id": article.id,
|
||||
"slug": article.slug,
|
||||
"locale": article.locale,
|
||||
"sortOrder": article.sort_order,
|
||||
"relatedArticleIds": article.related_article_ids,
|
||||
"status": article.status,
|
||||
"category": category_payload(article.category),
|
||||
"publishedRevision": (
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
import json
|
||||
from io import StringIO
|
||||
from unittest.mock import patch
|
||||
|
||||
from django.core.management import call_command
|
||||
|
||||
from chatballs.support_portals.tests.base import SupportPortalTestCase
|
||||
|
||||
|
||||
class ArticleCurationTests(SupportPortalTestCase):
|
||||
def setUp(self) -> None:
|
||||
super().setUp()
|
||||
portal = self.create_portal().json()["portal"]
|
||||
self.portal_id = portal["id"]
|
||||
self.portal_host = portal["hostedDomain"]
|
||||
self.root = self._category("Начало", "start")
|
||||
self.child = self._category("Продолжение", "next", parent_id=self.root)
|
||||
|
||||
def _category(self, name: str, slug: str, *, parent_id: int | None = None) -> int:
|
||||
payload = {"name": name, "slug": slug}
|
||||
if parent_id is not None:
|
||||
payload["parentId"] = parent_id
|
||||
response = self.client.post(
|
||||
f"/api/v1/support/portals/{self.portal_id}/categories/",
|
||||
payload,
|
||||
format="json",
|
||||
)
|
||||
self.assertEqual(response.status_code, 201, response.content)
|
||||
return response.json()["category"]["id"]
|
||||
|
||||
def _article(self, slug: str, title: str, category_id: int) -> int:
|
||||
response = self.client.post(
|
||||
f"/api/v1/support/portals/{self.portal_id}/articles/",
|
||||
{
|
||||
"categoryId": category_id,
|
||||
"slug": slug,
|
||||
"title": title,
|
||||
"summary": title,
|
||||
"content": title,
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
self.assertEqual(response.status_code, 201, response.content)
|
||||
article = response.json()["article"]
|
||||
published = self.client.post(
|
||||
f"/api/v1/support/portals/{self.portal_id}/articles/{article['id']}/publish/",
|
||||
{"revisionId": article["revisions"][0]["id"]},
|
||||
format="json",
|
||||
)
|
||||
self.assertEqual(published.status_code, 200, published.content)
|
||||
return article["id"]
|
||||
|
||||
def test_order_related_articles_and_direct_category(self) -> None:
|
||||
first = self._article("first", "Альфа", self.root)
|
||||
second = self._article("second", "Бета", self.root)
|
||||
child = self._article("child", "Гамма", self.child)
|
||||
base = f"/api/v1/support/portals/{self.portal_id}/articles/"
|
||||
for article_id, data in (
|
||||
(first, {"sortOrder": 2, "relatedArticleIds": [child, second]}),
|
||||
(second, {"sortOrder": 1}),
|
||||
):
|
||||
response = self.client.patch(
|
||||
f"{base}{article_id}/", data, format="json"
|
||||
)
|
||||
self.assertEqual(response.status_code, 200, response.content)
|
||||
invalid = self.client.patch(
|
||||
f"{base}{first}/",
|
||||
{"relatedArticleIds": [first]},
|
||||
format="json",
|
||||
)
|
||||
self.assertEqual(invalid.status_code, 400)
|
||||
self.client.post(
|
||||
f"/api/v1/support/portals/{self.portal_id}/status/",
|
||||
{"status": "PUBLISHED"},
|
||||
format="json",
|
||||
)
|
||||
self.client.logout()
|
||||
listing = self.client.get(
|
||||
"/api/v1/help/articles/?category=start&direct=1",
|
||||
HTTP_HOST=self.portal_host,
|
||||
)
|
||||
self.assertEqual(listing.status_code, 200, listing.content)
|
||||
self.assertEqual(
|
||||
[item["slug"] for item in listing.json()["items"]],
|
||||
["second", "first"],
|
||||
)
|
||||
detail = self.client.get(
|
||||
"/api/v1/help/articles/first/", HTTP_HOST=self.portal_host
|
||||
)
|
||||
self.assertEqual(detail.status_code, 200, detail.content)
|
||||
self.assertEqual(
|
||||
[item["slug"] for item in detail.json()["article"]["relatedArticles"]],
|
||||
["child", "second"],
|
||||
)
|
||||
|
||||
def test_curation_command_dry_run_and_apply(self) -> None:
|
||||
first = self._article("first", "Альфа", self.root)
|
||||
second = self._article("second", "Бета", self.root)
|
||||
self.client.post(
|
||||
f"/api/v1/support/portals/{self.portal_id}/status/",
|
||||
{"status": "PUBLISHED"},
|
||||
format="json",
|
||||
)
|
||||
plan = json.dumps({
|
||||
"portal": "app-help",
|
||||
"locale": "ru",
|
||||
"order": {"start": ["second", "first"]},
|
||||
"related": {"first": ["second"], "second": ["first"]},
|
||||
})
|
||||
output = StringIO()
|
||||
with patch("sys.stdin", StringIO(plan)):
|
||||
call_command(
|
||||
"apply_portal_curation", host=self.portal_host, file="-", stdout=output
|
||||
)
|
||||
self.assertIn("Would update 2 articles", output.getvalue())
|
||||
output = StringIO()
|
||||
with patch("sys.stdin", StringIO(plan)):
|
||||
call_command(
|
||||
"apply_portal_curation", host=self.portal_host, file="-", apply=True,
|
||||
stdout=output,
|
||||
)
|
||||
self.assertIn("Updated 2 articles", output.getvalue())
|
||||
self.client.logout()
|
||||
listing = self.client.get(
|
||||
"/api/v1/help/articles/?category=start&direct=1",
|
||||
HTTP_HOST=self.portal_host,
|
||||
)
|
||||
self.assertEqual(
|
||||
[item["slug"] for item in listing.json()["items"]],
|
||||
["second", "first"],
|
||||
)
|
||||
detail = self.client.get(
|
||||
"/api/v1/help/articles/first/", HTTP_HOST=self.portal_host
|
||||
)
|
||||
self.assertEqual(
|
||||
[item["slug"] for item in detail.json()["article"]["relatedArticles"]],
|
||||
["second"],
|
||||
)
|
||||
self.assertNotEqual(first, second)
|
||||
@@ -61,6 +61,7 @@ export function ChatPage({
|
||||
? <Hint id="chat-visibility">{t("admin.see_every_conversation_organization_operators")}</Hint>
|
||||
: undefined}
|
||||
viewerId={user.id}
|
||||
sender={{ operatorName: user.fullName, company: user.organizationName }}
|
||||
renderContextPanel={({ dialog, detail, applyConversation, startCall, closeContext, assignmentTimeoutMinutes }) => (
|
||||
<ChatContextPanel
|
||||
rightTab={rightTab}
|
||||
|
||||
@@ -1,24 +1,25 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
import { Icon, LogoSpinner } from "../../shared/icons";
|
||||
import { ComposerTemplatesMenu } from "./ComposerTemplatesMenu";
|
||||
import { EmojiPicker } from "./EmojiPicker";
|
||||
import { useMediaQuery } from "../../shared/useMediaQuery";
|
||||
import { fetchReplyTemplates, sendFileMessage, sendOperatorMessage, sendVoiceMessage, type ReplyTemplateRef } from "./model";
|
||||
import { sendFileMessage, sendOperatorMessage, sendVoiceMessage } from "./model";
|
||||
import { formatSize } from "../ai/knowledge/model";
|
||||
import { formatDuration } from "./VoiceMessage";
|
||||
import { TEMPLATE_VARIABLE_LABEL, unfilledVariables, type TemplateValues } from "./templateVariables";
|
||||
import { useComposerTemplates } from "./useComposerTemplates";
|
||||
import { useVoiceRecorder } from "./useVoiceRecorder";
|
||||
import type { ChannelKey, ControlMode } from "./types";
|
||||
import { t } from "../../i18n";
|
||||
|
||||
const MAX_FILE_BYTES = 20 * 1024 * 1024;
|
||||
|
||||
export function Composer({ mode, loaded, assignedOperatorName, conversationId, channel, voiceAllowed = true, onClaim, onRelease, onReturnQueue, onClose, onSent }: { mode: ControlMode; loaded: boolean; assignedOperatorName?: string; conversationId: number | null; channel?: ChannelKey; voiceAllowed?: boolean; onClaim: () => void; onRelease: () => void; onReturnQueue: () => void; onClose: () => void; onSent: () => void }) {
|
||||
export function Composer({ mode, loaded, assignedOperatorName, conversationId, channel, voiceAllowed = true, templateValues = {}, onClaim, onRelease, onReturnQueue, onClose, onSent }: { mode: ControlMode; loaded: boolean; assignedOperatorName?: string; conversationId: number | null; channel?: ChannelKey; voiceAllowed?: boolean; templateValues?: TemplateValues; onClaim: () => void; onRelease: () => void; onReturnQueue: () => void; onClose: () => void; onSent: () => void }) {
|
||||
const [text, setText] = useState("");
|
||||
const compact = useMediaQuery("(max-width: 768px)");
|
||||
const [sending, setSending] = useState(false);
|
||||
const [sendError, setSendError] = useState("");
|
||||
const [templates, setTemplates] = useState<ReplyTemplateRef[]>([]);
|
||||
const [templatesOpen, setTemplatesOpen] = useState(false);
|
||||
const [attachment, setAttachment] = useState<File | null>(null);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
@@ -35,25 +36,10 @@ export function Composer({ mode, loaded, assignedOperatorName, conversationId, c
|
||||
// Сброс черновика вложения при смене диалога.
|
||||
useEffect(() => { setAttachment(null); setSendError(""); }, [conversationId]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchReplyTemplates().then(setTemplates).catch(() => setTemplates([]));
|
||||
}, []);
|
||||
|
||||
// Шаблоны «/» (дизайн-базлайн v2 §9): ввод «/» в начале открывает список,
|
||||
// продолжение ввода фильтрует по названию.
|
||||
const slashQuery = text.startsWith("/") ? text.slice(1).trim().toLowerCase() : null;
|
||||
const visibleTemplates = useMemo(() => {
|
||||
if (templates.length === 0) return [];
|
||||
if (slashQuery === null) return templates;
|
||||
return templates.filter((template) => template.title.toLowerCase().includes(slashQuery));
|
||||
}, [templates, slashQuery]);
|
||||
const menuOpen = templatesOpen || (slashQuery !== null && visibleTemplates.length > 0);
|
||||
|
||||
function applyTemplate(template: ReplyTemplateRef) {
|
||||
setText(template.text);
|
||||
setTemplatesOpen(false);
|
||||
textareaRef.current?.focus();
|
||||
}
|
||||
const templates = useComposerTemplates({ text, setText, values: templateValues, textareaRef });
|
||||
// Переменная шаблона без значения (у гостя нет имени) остаётся в тексте:
|
||||
// пока оператор её не заполнит, ответ не уходит.
|
||||
const unfilled = unfilledVariables(text);
|
||||
|
||||
// Запись голосового: во всех каналах (TG/MAX sendVoice, почта — вложением, Web — поллингом).
|
||||
const recorder = useVoiceRecorder({
|
||||
@@ -111,7 +97,7 @@ export function Composer({ mode, loaded, assignedOperatorName, conversationId, c
|
||||
|
||||
async function send() {
|
||||
const value = text.trim();
|
||||
if ((!value && !attachment) || conversationId == null || sending) return;
|
||||
if ((!value && !attachment) || conversationId == null || sending || unfilled.length > 0) return;
|
||||
setSending(true);
|
||||
setSendError("");
|
||||
try {
|
||||
@@ -163,7 +149,9 @@ export function Composer({ mode, loaded, assignedOperatorName, conversationId, c
|
||||
|
||||
// Кадры A–C: композер активен всегда (решение 2) — первое сообщение
|
||||
// перехватывает диалог; над полем одна строка-предупреждение.
|
||||
const warning = mode === "ai"
|
||||
const warning = unfilled.length > 0
|
||||
? { color: "var(--warning-text)", dot: "var(--warning)", text: t("conversations.fill_template_variables", { names: unfilled.map((name) => t(TEMPLATE_VARIABLE_LABEL[name]).toLocaleLowerCase()).join(", ") }) }
|
||||
: mode === "ai"
|
||||
? { color: "var(--ai)", text: t("conversations.ai_handling_conversation_message_takes") }
|
||||
: mode === "waiting"
|
||||
? { color: "var(--warning-text)", dot: "var(--warning)", text: t("conversations.customer_waiting_message_assigns_conversation") }
|
||||
@@ -174,17 +162,7 @@ export function Composer({ mode, loaded, assignedOperatorName, conversationId, c
|
||||
<div className="composer-wrap">
|
||||
{warning && <div className="composer-warning" style={{ color: warning.color }}><i style={{ background: warning.dot ?? warning.color }} />{warning.text}</div>}
|
||||
<div className="composer-box">
|
||||
{menuOpen && (
|
||||
<div className="composer-templates-menu">
|
||||
{visibleTemplates.length === 0 && <p>{t("conversations.no_matching_templates")}</p>}
|
||||
{visibleTemplates.map((template) => (
|
||||
<button key={template.id} type="button" onMouseDown={(event) => { event.preventDefault(); applyTemplate(template); }}>
|
||||
<strong>{template.title}</strong>
|
||||
<small>{template.text.replace(/\s+/g, " ").slice(0, 80)}</small>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{templates.menuOpen && <ComposerTemplatesMenu items={templates.visible} onPick={templates.apply} />}
|
||||
{attachment && (
|
||||
<div className="composer-attachment">
|
||||
<Icon name="paperclip" size={14} />
|
||||
@@ -202,14 +180,14 @@ export function Composer({ mode, loaded, assignedOperatorName, conversationId, c
|
||||
value={text}
|
||||
onChange={(event) => setText(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Escape" && menuOpen) { setTemplatesOpen(false); if (slashQuery !== null) setText(""); return; }
|
||||
if (event.key === "Escape" && templates.menuOpen) { templates.close(); if (templates.slashQuery !== null) setText(""); return; }
|
||||
if (event.key === "Enter" && !event.shiftKey) {
|
||||
if (slashQuery !== null && visibleTemplates.length > 0) { event.preventDefault(); applyTemplate(visibleTemplates[0]); return; }
|
||||
if (templates.slashQuery !== null && templates.visible.length > 0) { event.preventDefault(); templates.apply(templates.visible[0]); return; }
|
||||
event.preventDefault();
|
||||
void send();
|
||||
}
|
||||
}}
|
||||
onBlur={() => setTemplatesOpen(false)}
|
||||
onBlur={templates.close}
|
||||
onPaste={(event) => {
|
||||
const file = Array.from(event.clipboardData?.files ?? [])[0];
|
||||
if (file) { event.preventDefault(); pickFile(file); }
|
||||
@@ -226,12 +204,12 @@ export function Composer({ mode, loaded, assignedOperatorName, conversationId, c
|
||||
<Icon name="mic" size={17} />
|
||||
</button>
|
||||
)}
|
||||
{templates.length > 0 && (
|
||||
<button className="composer-tool is-labeled" title={t("conversations.reply_templates")} type="button" onClick={() => setTemplatesOpen((open) => !open)}>
|
||||
{templates.available && (
|
||||
<button className="composer-tool is-labeled" title={t("conversations.reply_templates")} type="button" onClick={templates.toggle}>
|
||||
<Icon name="text" size={16} />{t("conversations.templates")}</button>
|
||||
)}
|
||||
<span className="composer-spacer" />
|
||||
<button className="composer-send" type="button" onClick={() => void send()} disabled={sending || (!text.trim() && !attachment)}><span>{t("conversations.send")}</span><kbd>⏎</kbd><Icon name="send" size={17} /></button>
|
||||
<button className="composer-send" type="button" onClick={() => void send()} disabled={sending || (!text.trim() && !attachment) || unfilled.length > 0}><span>{t("conversations.send")}</span><kbd>⏎</kbd><Icon name="send" size={17} /></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { ReplyTemplateRef } from "./model";
|
||||
import { t } from "../../i18n";
|
||||
|
||||
export function ComposerTemplatesMenu({ items, onPick }: { items: ReplyTemplateRef[]; onPick: (template: ReplyTemplateRef) => void }) {
|
||||
return (
|
||||
<div className="composer-templates-menu">
|
||||
{items.length === 0 && <p>{t("conversations.no_matching_templates")}</p>}
|
||||
{items.map((template) => (
|
||||
<button key={template.id} type="button" onMouseDown={(event) => { event.preventDefault(); onPick(template); }}>
|
||||
<strong>{template.title}</strong>
|
||||
<small>{template.text.replace(/\s+/g, " ").slice(0, 80)}</small>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -57,7 +57,7 @@ import { t } from "../../i18n";
|
||||
// backend по группам (ADR-CHATBALLS-0043); страница параметризуется заголовком,
|
||||
// placeholder поиска и правой панелью через render-prop. Список и история —
|
||||
// серверные окна: ни то, ни другое целиком не запрашивается.
|
||||
export function ConversationWorkspace({ isOwner = false, canDelete = false, viewerId = null, listTitle, searchPlaceholder, renderContextPanel, mobileHeader, hint, initialConversationId, scope, setScope, counters, showScopeSwitcher = true }: {
|
||||
export function ConversationWorkspace({ isOwner = false, canDelete = false, viewerId = null, listTitle, searchPlaceholder, renderContextPanel, mobileHeader, hint, initialConversationId, scope, setScope, counters, showScopeSwitcher = true, sender }: {
|
||||
isOwner?: boolean;
|
||||
/** Удалять диалоги могут владелец и администратор (то же проверяет сервер). */
|
||||
canDelete?: boolean;
|
||||
@@ -74,6 +74,8 @@ export function ConversationWorkspace({ isOwner = false, canDelete = false, view
|
||||
setScope: (scope: DialogScope) => void;
|
||||
counters: ConversationCounters | null;
|
||||
showScopeSwitcher?: boolean;
|
||||
/** Кто отвечает — для переменных шаблонов ответов. */
|
||||
sender?: { operatorName: string; company: string };
|
||||
}) {
|
||||
const [listTab, setListTab] = useState<ListTab>("all");
|
||||
const [sort, setSort] = useState<ListSort>("activity");
|
||||
@@ -289,6 +291,11 @@ export function ConversationWorkspace({ isOwner = false, canDelete = false, view
|
||||
mode={controlMode}
|
||||
channel={selectedDialog?.channel}
|
||||
voiceAllowed={detail?.connection?.voiceMessages ?? false}
|
||||
templateValues={{
|
||||
client_name: detail?.contact?.isGuest ? "" : detail?.contact?.name,
|
||||
operator_name: sender?.operatorName,
|
||||
company: sender?.company,
|
||||
}}
|
||||
loaded={detailLoaded}
|
||||
assignedOperatorName={detail?.assignedOperator?.name}
|
||||
conversationId={selectedId}
|
||||
|
||||
@@ -72,7 +72,8 @@ export type ApiConversation = {
|
||||
connection: { id: number; provider: "EMAIL" | "MAX" | "TELEGRAM" | "VK" | "WEB"; name: string; voiceMessages?: boolean; audioCalls?: boolean; videoCalls?: boolean } | null;
|
||||
// Контакт — единственный источник identity диалога.
|
||||
// phone появляется после явного шаринга контакта; username (@логин TG/MAX) — только в detail-режиме.
|
||||
contact: { id: number; name: string; email?: string; phone?: string; username?: string; avatarUrl?: string; description?: string; company?: string; city?: string } | null;
|
||||
// isGuest — только в detail-режиме: имя гостя виджета — подпись «Гость · код», а не имя.
|
||||
contact: { id: number; name: string; email?: string; phone?: string; username?: string; avatarUrl?: string; description?: string; company?: string; city?: string; isGuest?: boolean } | null;
|
||||
lifecycle: "OPEN" | "CLOSED" | "SPAM";
|
||||
controlMode: "AI" | "HUMAN" | "PAUSED";
|
||||
expectedResponder: string;
|
||||
@@ -371,6 +372,18 @@ export const createConversationLabel = (name: string, color = "") =>
|
||||
}).then((r) => r.label);
|
||||
export const fetchReplyTemplates = () =>
|
||||
api<{ items: ReplyTemplateRef[] }>("/api/v1/conversations/templates/").then((r) => r.items);
|
||||
export const createReplyTemplate = (title: string, text: string) =>
|
||||
api<{ template: ReplyTemplateRef }>("/api/v1/conversations/templates/", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ title, text }),
|
||||
}).then((r) => r.template);
|
||||
export const updateReplyTemplate = (id: number, title: string, text: string) =>
|
||||
api<{ template: ReplyTemplateRef }>(`/api/v1/conversations/templates/${id}/`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({ title, text }),
|
||||
}).then((r) => r.template);
|
||||
export const deleteReplyTemplate = (id: number) =>
|
||||
api<void>(`/api/v1/conversations/templates/${id}/`, { method: "DELETE" });
|
||||
|
||||
export const transcribeMessage = (messageId: number) =>
|
||||
api<{ message: ApiMessage }>(`/api/v1/conversations/messages/${messageId}/transcribe/`, { method: "POST" }).then((r) => r.message);
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { renderTemplate, unfilledVariables } from "./templateVariables";
|
||||
|
||||
describe("переменные шаблона ответа", () => {
|
||||
it("подставляет известные значения", () => {
|
||||
const text = renderTemplate("Здравствуйте, {{client_name}}! Меня зовут {{ operator_name }}, {{company}}.", {
|
||||
client_name: "Дмитрий",
|
||||
operator_name: "Анна",
|
||||
company: "Ателье Норд",
|
||||
});
|
||||
expect(text).toBe("Здравствуйте, Дмитрий! Меня зовут Анна, Ателье Норд.");
|
||||
});
|
||||
|
||||
it("оставляет переменную без значения и чужие скобки как есть", () => {
|
||||
const text = renderTemplate("Здравствуйте, {{client_name}}! {{unknown}}", { client_name: " ", company: "Ателье Норд" });
|
||||
expect(text).toBe("Здравствуйте, {{client_name}}! {{unknown}}");
|
||||
});
|
||||
|
||||
it("находит незаполненные переменные без повторов", () => {
|
||||
expect(unfilledVariables("{{client_name}}, {{client_name}} и {{company}}, {{unknown}}")).toEqual(["client_name", "company"]);
|
||||
expect(unfilledVariables("Готовый текст")).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
import type { MessageKey } from "../../i18n/ru";
|
||||
|
||||
// Переменные шаблонов ответов. В тексте шаблона хранится код, а не слово на
|
||||
// языке интерфейса: язык организации меняется, код — нет. Тот же список
|
||||
// проверяет сервер при сохранении (conversations/chat_extras_views.py).
|
||||
|
||||
export const TEMPLATE_VARIABLES = ["client_name", "operator_name", "company"] as const;
|
||||
|
||||
export type TemplateVariable = (typeof TEMPLATE_VARIABLES)[number];
|
||||
export type TemplateValues = Partial<Record<TemplateVariable, string>>;
|
||||
|
||||
export const TEMPLATE_VARIABLE_LABEL: Record<TemplateVariable, MessageKey> = {
|
||||
client_name: "conversations.variable_client_name",
|
||||
operator_name: "conversations.variable_operator_name",
|
||||
company: "conversations.variable_company",
|
||||
};
|
||||
|
||||
const TOKEN = /\{\{\s*(\w+)\s*\}\}/g;
|
||||
|
||||
export function variableToken(variable: TemplateVariable): string {
|
||||
return `{{${variable}}}`;
|
||||
}
|
||||
|
||||
function isVariable(name: string): name is TemplateVariable {
|
||||
return (TEMPLATE_VARIABLES as readonly string[]).includes(name);
|
||||
}
|
||||
|
||||
/** Подставляет известные значения. Переменная без значения (у гостя нет
|
||||
* имени) остаётся в тексте как есть — её заполнит оператор. */
|
||||
export function renderTemplate(text: string, values: TemplateValues): string {
|
||||
return text.replace(TOKEN, (token, name: string) => {
|
||||
const value = isVariable(name) ? values[name]?.trim() : "";
|
||||
return value || token;
|
||||
});
|
||||
}
|
||||
|
||||
/** Переменные, которые остались в тексте незаполненными, без повторов. */
|
||||
export function unfilledVariables(text: string): TemplateVariable[] {
|
||||
const found = [...text.matchAll(TOKEN)].map((match) => match[1]).filter(isVariable);
|
||||
return [...new Set(found)];
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { useEffect, useMemo, useState, type RefObject } from "react";
|
||||
|
||||
import { fetchReplyTemplates, type ReplyTemplateRef } from "./model";
|
||||
import { renderTemplate, type TemplateValues } from "./templateVariables";
|
||||
|
||||
// Шаблоны «/» (дизайн-базлайн v2 §9): ввод «/» в начале открывает список,
|
||||
// продолжение ввода фильтрует по названию. Выбранный шаблон заменяет текст
|
||||
// поля, переменные подставляются сразу — оператор видит готовый ответ.
|
||||
|
||||
export function useComposerTemplates({ text, setText, values, textareaRef }: {
|
||||
text: string;
|
||||
setText: (text: string) => void;
|
||||
values: TemplateValues;
|
||||
textareaRef: RefObject<HTMLTextAreaElement | null>;
|
||||
}) {
|
||||
const [templates, setTemplates] = useState<ReplyTemplateRef[]>([]);
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
fetchReplyTemplates().then(setTemplates).catch(() => setTemplates([]));
|
||||
}, []);
|
||||
|
||||
const slashQuery = text.startsWith("/") ? text.slice(1).trim().toLowerCase() : null;
|
||||
const visible = useMemo(() => {
|
||||
if (templates.length === 0) return [];
|
||||
if (slashQuery === null) return templates;
|
||||
return templates.filter((template) => template.title.toLowerCase().includes(slashQuery));
|
||||
}, [templates, slashQuery]);
|
||||
|
||||
function apply(template: ReplyTemplateRef) {
|
||||
setText(renderTemplate(template.text, values));
|
||||
setOpen(false);
|
||||
textareaRef.current?.focus();
|
||||
}
|
||||
|
||||
return {
|
||||
available: templates.length > 0,
|
||||
visible,
|
||||
slashQuery,
|
||||
menuOpen: open || (slashQuery !== null && visible.length > 0),
|
||||
toggle: () => setOpen((current) => !current),
|
||||
close: () => setOpen(false),
|
||||
apply,
|
||||
};
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { fetchHelpArticle, sendArticleFeedback } from "./api";
|
||||
import { HelpChevronIcon, HelpThumbIcon } from "./HelpIcons";
|
||||
import { HelpLayout } from "./HelpLayout";
|
||||
import { HelpAttachments } from "./HelpAttachments";
|
||||
import { HelpRelatedArticles } from "./HelpRelatedArticles";
|
||||
import { MarkdownContent, parseMarkdown } from "./MarkdownContent";
|
||||
import type { HelpArticle as HelpArticleType, HelpManifest } from "./types";
|
||||
import { t } from "../../i18n";
|
||||
@@ -63,7 +64,9 @@ export function HelpArticle({
|
||||
return (
|
||||
<HelpLayout
|
||||
compactHeader
|
||||
portal={manifest.portal}
|
||||
manifest={manifest}
|
||||
activeCategoryId={article?.category.id}
|
||||
activeArticleSlug={article?.slug}
|
||||
search={search}
|
||||
onSearchChange={setSearch}
|
||||
onSearchSubmit={searchSubmit}
|
||||
@@ -128,6 +131,7 @@ export function HelpArticle({
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
<HelpRelatedArticles articles={article.relatedArticles ?? []} />
|
||||
</article>
|
||||
{headings.length > 0 && (
|
||||
<aside className="help-toc" aria-label={t("portals.article_contents")}>
|
||||
|
||||
@@ -12,6 +12,8 @@ import type { HelpManifest } from "./types";
|
||||
import "./styles-layout.css";
|
||||
import "./styles-home.css";
|
||||
import "./styles-article.css";
|
||||
import "./styles-navigation.css";
|
||||
import "./styles-related.css";
|
||||
import "./styles-responsive.css";
|
||||
import { t } from "../../i18n";
|
||||
|
||||
|
||||
@@ -80,7 +80,8 @@ export function HelpHome({ manifest }: { manifest: HelpManifest }) {
|
||||
|
||||
return (
|
||||
<HelpLayout
|
||||
portal={manifest.portal}
|
||||
manifest={manifest}
|
||||
activeCategoryId={selectedCategory?.id}
|
||||
search={search}
|
||||
onSearchChange={setSearch}
|
||||
>
|
||||
|
||||
@@ -2,26 +2,32 @@ import type { ReactNode } from "react";
|
||||
|
||||
import { LogoIcon } from "../../shared/icons";
|
||||
import { HelpSearch } from "./HelpSearch";
|
||||
import { HelpNavigation } from "./HelpNavigation";
|
||||
import { PortalWebWidget } from "./PortalWebWidget";
|
||||
import type { HelpPortal } from "./types";
|
||||
import type { HelpManifest } from "./types";
|
||||
import { t } from "../../i18n";
|
||||
|
||||
export function HelpLayout({
|
||||
portal,
|
||||
manifest,
|
||||
children,
|
||||
search,
|
||||
onSearchChange,
|
||||
onSearchSubmit,
|
||||
compactHeader = false,
|
||||
activeCategoryId = null,
|
||||
activeArticleSlug = null,
|
||||
}: {
|
||||
portal: HelpPortal;
|
||||
manifest: HelpManifest;
|
||||
children: ReactNode;
|
||||
search: string;
|
||||
onSearchChange: (value: string) => void;
|
||||
onSearchSubmit?: (value: string) => void;
|
||||
compactHeader?: boolean;
|
||||
activeCategoryId?: number | null;
|
||||
activeArticleSlug?: string | null;
|
||||
}) {
|
||||
const homeHref = "/";
|
||||
const portal = manifest.portal;
|
||||
return (
|
||||
<div className={`help-center ${compactHeader ? "has-compact-header" : ""}`}>
|
||||
<header className="help-header">
|
||||
@@ -40,7 +46,14 @@ export function HelpLayout({
|
||||
/>
|
||||
)}
|
||||
</header>
|
||||
<main>{children}</main>
|
||||
<main className="help-page-grid">
|
||||
<HelpNavigation
|
||||
manifest={manifest}
|
||||
activeCategoryId={activeCategoryId}
|
||||
activeArticleSlug={activeArticleSlug}
|
||||
/>
|
||||
<div className="help-page-content">{children}</div>
|
||||
</main>
|
||||
<footer className="help-footer">
|
||||
<div className="help-footer-row">
|
||||
<a className="help-footer-brand" href={homeHref}>
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
|
||||
import { fetchHelpArticles } from "./api";
|
||||
import { HelpChevronIcon } from "./HelpIcons";
|
||||
import type { HelpArticle, HelpCategory, HelpManifest } from "./types";
|
||||
import { t } from "../../i18n";
|
||||
|
||||
function articleHref(slug: string): string {
|
||||
return `/articles/${encodeURIComponent(slug)}/`;
|
||||
}
|
||||
|
||||
type BranchProps = {
|
||||
category: HelpCategory;
|
||||
childrenByParent: Map<number | null, HelpCategory[]>;
|
||||
activePath: Set<number>;
|
||||
activeCategoryId: number | null;
|
||||
activeArticleSlug: string | null;
|
||||
locale: string;
|
||||
};
|
||||
|
||||
function CategoryBranch({
|
||||
category, childrenByParent, activePath, activeCategoryId, activeArticleSlug, locale,
|
||||
}: BranchProps) {
|
||||
const [expanded, setExpanded] = useState(activePath.has(category.id));
|
||||
const [articles, setArticles] = useState<HelpArticle[] | null>(null);
|
||||
const [hasMore, setHasMore] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [failed, setFailed] = useState(false);
|
||||
const children = childrenByParent.get(category.id) ?? [];
|
||||
|
||||
useEffect(() => {
|
||||
if (activePath.has(category.id)) setExpanded(true);
|
||||
}, [activePath, category.id]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!expanded || articles !== null || category.articleCount === 0) return;
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
fetchHelpArticles({ locale, category: category.slug, direct: true, limit: 50 })
|
||||
.then((payload) => {
|
||||
if (cancelled) return;
|
||||
setArticles(payload.items);
|
||||
setHasMore(payload.pagination.hasMore);
|
||||
setFailed(false);
|
||||
})
|
||||
.catch(() => { if (!cancelled) setFailed(true); })
|
||||
.finally(() => { if (!cancelled) setLoading(false); });
|
||||
return () => { cancelled = true; };
|
||||
}, [articles, category.articleCount, category.slug, expanded, locale]);
|
||||
|
||||
async function loadMore() {
|
||||
if (loading || !articles) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const payload = await fetchHelpArticles({
|
||||
locale, category: category.slug, direct: true, limit: 50, offset: articles.length,
|
||||
});
|
||||
setArticles((current) => [...(current ?? []), ...payload.items]);
|
||||
setHasMore(payload.pagination.hasMore);
|
||||
setFailed(false);
|
||||
} catch {
|
||||
setFailed(true);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
const canExpand = category.articleCount > 0 || children.length > 0;
|
||||
return (
|
||||
<li className="help-nav-branch">
|
||||
<div className="help-nav-row">
|
||||
<a
|
||||
aria-current={activeCategoryId === category.id && !activeArticleSlug ? "page" : undefined}
|
||||
className={activeCategoryId === category.id ? "is-current" : ""}
|
||||
href={`/?category=${encodeURIComponent(category.slug)}`}
|
||||
>{category.name}</a>
|
||||
{canExpand && (
|
||||
<button
|
||||
aria-label={t(expanded ? "portals.collapse_section" : "portals.expand_section", { name: category.name })}
|
||||
aria-expanded={expanded}
|
||||
className={expanded ? "is-expanded" : ""}
|
||||
type="button"
|
||||
onClick={() => setExpanded((value) => !value)}
|
||||
><HelpChevronIcon /></button>
|
||||
)}
|
||||
</div>
|
||||
{canExpand && expanded && (
|
||||
<ul className="help-nav-children">
|
||||
{articles?.map((article) => (
|
||||
<li key={article.slug}>
|
||||
<a
|
||||
aria-current={activeArticleSlug === article.slug ? "page" : undefined}
|
||||
className={`help-nav-article${activeArticleSlug === article.slug ? " is-current" : ""}`}
|
||||
href={articleHref(article.slug)}
|
||||
>{article.revision.title}</a>
|
||||
</li>
|
||||
))}
|
||||
{children.map((child) => (
|
||||
<CategoryBranch
|
||||
key={child.id}
|
||||
category={child}
|
||||
childrenByParent={childrenByParent}
|
||||
activePath={activePath}
|
||||
activeCategoryId={activeCategoryId}
|
||||
activeArticleSlug={activeArticleSlug}
|
||||
locale={locale}
|
||||
/>
|
||||
))}
|
||||
{loading && <li className="help-nav-status" aria-live="polite">…</li>}
|
||||
{failed && <li className="help-nav-status">{t("portals.could_not_load_articles")}</li>}
|
||||
{hasMore && !loading && (
|
||||
<li><button className="help-nav-more" type="button" onClick={() => void loadMore()}>{t("portals.show_more")}</button></li>
|
||||
)}
|
||||
</ul>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
export function HelpNavigation({
|
||||
manifest, activeCategoryId, activeArticleSlug,
|
||||
}: {
|
||||
manifest: HelpManifest;
|
||||
activeCategoryId: number | null;
|
||||
activeArticleSlug: string | null;
|
||||
}) {
|
||||
const [mobileOpen, setMobileOpen] = useState(false);
|
||||
const childrenByParent = useMemo(() => {
|
||||
const result = new Map<number | null, HelpCategory[]>();
|
||||
for (const category of manifest.categories) {
|
||||
const siblings = result.get(category.parentId) ?? [];
|
||||
siblings.push(category);
|
||||
result.set(category.parentId, siblings);
|
||||
}
|
||||
for (const siblings of result.values()) {
|
||||
siblings.sort((a, b) => a.sortOrder - b.sortOrder || a.name.localeCompare(b.name));
|
||||
}
|
||||
return result;
|
||||
}, [manifest.categories]);
|
||||
const activePath = useMemo(() => {
|
||||
const byId = new Map(manifest.categories.map((category) => [category.id, category]));
|
||||
const path = new Set<number>();
|
||||
let current = activeCategoryId ? byId.get(activeCategoryId) : undefined;
|
||||
while (current && !path.has(current.id)) {
|
||||
path.add(current.id);
|
||||
current = current.parentId ? byId.get(current.parentId) : undefined;
|
||||
}
|
||||
return path;
|
||||
}, [activeCategoryId, manifest.categories]);
|
||||
|
||||
return (
|
||||
<aside className="help-navigation">
|
||||
<button
|
||||
aria-controls="help-navigation-tree"
|
||||
aria-expanded={mobileOpen}
|
||||
className="help-navigation-mobile-toggle"
|
||||
type="button"
|
||||
onClick={() => setMobileOpen((value) => !value)}
|
||||
>{t("portals.all_sections")}<HelpChevronIcon /></button>
|
||||
<nav aria-label={t("portals.navigation")} className={mobileOpen ? "is-open" : ""} id="help-navigation-tree">
|
||||
<a className="help-nav-all" href="/">{t("portals.all_sections")}</a>
|
||||
<ul>
|
||||
{(childrenByParent.get(null) ?? []).map((category) => (
|
||||
<CategoryBranch
|
||||
key={category.id}
|
||||
category={category}
|
||||
childrenByParent={childrenByParent}
|
||||
activePath={activePath}
|
||||
activeCategoryId={activeCategoryId}
|
||||
activeArticleSlug={activeArticleSlug}
|
||||
locale={manifest.portal.defaultLocale}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
</nav>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { HelpArrowIcon } from "./HelpIcons";
|
||||
import type { HelpArticle } from "./types";
|
||||
import { t } from "../../i18n";
|
||||
|
||||
export function HelpRelatedArticles({ articles }: { articles: HelpArticle[] }) {
|
||||
if (articles.length === 0) return null;
|
||||
return (
|
||||
<section className="help-related" aria-labelledby="help-related-title">
|
||||
<h2 id="help-related-title">{t("portals.related_articles")}</h2>
|
||||
<ul>
|
||||
{articles.map((article) => (
|
||||
<li key={article.slug}>
|
||||
<a href={`/articles/${encodeURIComponent(article.slug)}/`}>
|
||||
<span>{article.revision.title}</span>
|
||||
<HelpArrowIcon />
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -6,13 +6,15 @@ export function fetchHelpManifest(): Promise<HelpManifest> {
|
||||
}
|
||||
|
||||
export function fetchHelpArticles(
|
||||
options: { locale?: string; category?: string; query?: string; offset?: number } = {},
|
||||
options: { locale?: string; category?: string; query?: string; offset?: number; limit?: number; direct?: boolean } = {},
|
||||
): Promise<{ items: HelpArticle[]; pagination: { hasMore: boolean; limit: number; offset: number; total: number } }> {
|
||||
const search = new URLSearchParams();
|
||||
if (options.locale) search.set("locale", options.locale);
|
||||
if (options.category) search.set("category", options.category);
|
||||
if (options.query) search.set("q", options.query);
|
||||
if (options.offset) search.set("offset", String(options.offset));
|
||||
if (options.limit) search.set("limit", String(options.limit));
|
||||
if (options.direct) search.set("direct", "1");
|
||||
const suffix = search.size ? `?${search.toString()}` : "";
|
||||
return api(`/api/v1/help/articles/${suffix}`);
|
||||
}
|
||||
|
||||
@@ -26,7 +26,7 @@ html.help-center-document body {
|
||||
}
|
||||
|
||||
.help-header {
|
||||
width: min(1024px, calc(100% - 48px));
|
||||
width: min(1280px, calc(100% - 48px));
|
||||
margin: 0 auto;
|
||||
padding: 34px 0 0;
|
||||
}
|
||||
@@ -154,7 +154,7 @@ html.help-center-document body {
|
||||
|
||||
.help-footer {
|
||||
display: grid;
|
||||
width: min(1024px, calc(100% - 48px));
|
||||
width: min(1280px, calc(100% - 48px));
|
||||
margin: 80px auto 0;
|
||||
padding: 34px 0 42px;
|
||||
gap: 18px;
|
||||
@@ -293,4 +293,3 @@ html.help-center-document body {
|
||||
.help-fatal span {
|
||||
color: var(--help-text-muted);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
.help-page-grid {
|
||||
display: grid;
|
||||
width: min(1280px, calc(100% - 48px));
|
||||
margin: 0 auto;
|
||||
grid-template-columns: 240px minmax(0, 1fr);
|
||||
gap: 44px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.help-page-content {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.help-page-content .help-hero,
|
||||
.help-page-content .help-home-content,
|
||||
.help-page-content .help-article-shell {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.help-page-content .help-article-grid {
|
||||
grid-template-columns: minmax(0, 1fr) minmax(160px, 190px);
|
||||
gap: 32px;
|
||||
}
|
||||
|
||||
.help-navigation {
|
||||
position: sticky;
|
||||
top: 24px;
|
||||
max-height: calc(100vh - 48px);
|
||||
margin-top: 42px;
|
||||
padding-right: 18px;
|
||||
overflow: auto;
|
||||
border-right: 1px solid var(--help-line-soft);
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
|
||||
.help-navigation-mobile-toggle {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.help-navigation nav > ul,
|
||||
.help-nav-children {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.help-nav-all,
|
||||
.help-nav-row > a,
|
||||
.help-nav-article,
|
||||
.help-nav-more {
|
||||
display: block;
|
||||
padding: 9px 10px;
|
||||
border-radius: var(--help-radius-sm);
|
||||
color: var(--help-text);
|
||||
font-size: 14px;
|
||||
line-height: 1.35;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.help-nav-all {
|
||||
margin-bottom: 8px;
|
||||
font-weight: var(--help-heading-weight);
|
||||
}
|
||||
|
||||
.help-nav-row {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.help-nav-row > a {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
color: var(--help-ink);
|
||||
font-weight: var(--help-heading-weight);
|
||||
}
|
||||
|
||||
.help-nav-row > button {
|
||||
display: grid;
|
||||
width: 32px;
|
||||
height: 36px;
|
||||
flex: none;
|
||||
place-items: center;
|
||||
border: 0;
|
||||
border-radius: var(--help-radius-sm);
|
||||
background: transparent;
|
||||
color: var(--help-text-muted);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.help-nav-row > button svg {
|
||||
transition: transform 150ms ease;
|
||||
}
|
||||
|
||||
.help-nav-row > button.is-expanded svg {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.help-nav-children {
|
||||
margin: 1px 0 7px 15px;
|
||||
padding-left: 9px;
|
||||
border-left: 1px solid var(--help-line-soft);
|
||||
}
|
||||
|
||||
.help-nav-article {
|
||||
color: var(--help-text-muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.help-nav-all:hover,
|
||||
.help-nav-row > a:hover,
|
||||
.help-nav-article:hover,
|
||||
.help-nav-more:hover,
|
||||
.help-nav-row > button:hover {
|
||||
background: var(--help-surface-soft);
|
||||
color: var(--help-ink);
|
||||
}
|
||||
|
||||
.help-nav-row > a.is-current,
|
||||
.help-nav-article.is-current {
|
||||
background: var(--help-surface-soft);
|
||||
color: var(--help-accent);
|
||||
font-weight: var(--help-heading-weight);
|
||||
}
|
||||
|
||||
.help-nav-more {
|
||||
width: 100%;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.help-nav-status {
|
||||
padding: 9px 10px;
|
||||
color: var(--help-text-subtle);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
.help-page-content .help-article-grid {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.help-page-content .help-toc {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 860px) {
|
||||
.help-page-grid {
|
||||
display: block;
|
||||
width: min(100% - 36px, 680px);
|
||||
}
|
||||
|
||||
.help-navigation {
|
||||
position: static;
|
||||
max-height: none;
|
||||
margin-top: 20px;
|
||||
padding: 0;
|
||||
overflow: visible;
|
||||
border-right: 0;
|
||||
border-bottom: 1px solid var(--help-line-soft);
|
||||
}
|
||||
|
||||
.help-navigation-mobile-toggle {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
min-height: 44px;
|
||||
padding: 0 4px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--help-ink);
|
||||
font-family: var(--help-font-heading);
|
||||
font-weight: var(--help-heading-weight);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.help-navigation-mobile-toggle[aria-expanded="true"] svg {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.help-navigation nav {
|
||||
display: none;
|
||||
max-height: 60vh;
|
||||
padding: 8px 0 14px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.help-navigation nav.is-open {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 560px) {
|
||||
.help-page-grid {
|
||||
width: calc(100% - 28px);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
.help-related {
|
||||
margin-top: 46px;
|
||||
}
|
||||
|
||||
.help-related h2 {
|
||||
margin: 0 0 18px;
|
||||
font-family: var(--help-font-heading);
|
||||
font-size: 23px;
|
||||
font-weight: var(--help-heading-weight);
|
||||
letter-spacing: -0.03em;
|
||||
}
|
||||
|
||||
.help-related ul {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border-top: 1px solid var(--help-line);
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.help-related li {
|
||||
border-bottom: 1px solid var(--help-line-soft);
|
||||
}
|
||||
|
||||
.help-related a {
|
||||
display: flex;
|
||||
min-height: 52px;
|
||||
padding: 12px 2px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
color: var(--help-ink);
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
line-height: 1.4;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.help-related a svg {
|
||||
flex: none;
|
||||
color: var(--help-text-muted);
|
||||
}
|
||||
|
||||
.help-related a:hover {
|
||||
color: var(--help-accent);
|
||||
}
|
||||
|
||||
.help-related a:hover svg {
|
||||
color: var(--help-accent);
|
||||
}
|
||||
@@ -35,6 +35,7 @@ export type HelpArticle = {
|
||||
updatedAt: string;
|
||||
// Файлы статьи: картинки вставлены в текст, остальное посетитель скачивает.
|
||||
attachments?: HelpAttachment[];
|
||||
relatedArticles?: HelpArticle[];
|
||||
};
|
||||
|
||||
export type HelpPortal = {
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import { Modal } from "antd";
|
||||
import { useRef, useState } from "react";
|
||||
|
||||
import { FormField, TextAreaField } from "../../shared/form-controls";
|
||||
import { Button } from "../../shared/ui-controls";
|
||||
import { createReplyTemplate, updateReplyTemplate, type ReplyTemplateRef } from "../conversations/model";
|
||||
import { variableToken, type TemplateVariable } from "../conversations/templateVariables";
|
||||
import { TemplateVariableMenu } from "./TemplateVariableMenu";
|
||||
import { t } from "../../i18n";
|
||||
|
||||
// Окно шаблона ответа: название и текст. Та же форма, что у интеграций и
|
||||
// переименования группы; ошибки (занятое название, пустой текст) — от сервера.
|
||||
|
||||
export function ReplyTemplateForm({ initial, onClose, onSaved }: {
|
||||
initial: ReplyTemplateRef | null;
|
||||
onClose: () => void;
|
||||
onSaved: () => void;
|
||||
}) {
|
||||
const [title, setTitle] = useState(initial?.title ?? "");
|
||||
const [text, setText] = useState(initial?.text ?? "");
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const textRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
function insertVariable(variable: TemplateVariable) {
|
||||
const token = variableToken(variable);
|
||||
const area = textRef.current;
|
||||
const start = area?.selectionStart ?? text.length;
|
||||
const end = area?.selectionEnd ?? text.length;
|
||||
setText(text.slice(0, start) + token + text.slice(end));
|
||||
window.requestAnimationFrame(() => {
|
||||
if (!area) return;
|
||||
area.focus();
|
||||
area.setSelectionRange(start + token.length, start + token.length);
|
||||
});
|
||||
}
|
||||
|
||||
async function save() {
|
||||
setSaving(true);
|
||||
setError("");
|
||||
try {
|
||||
if (initial) await updateReplyTemplate(initial.id, title.trim(), text.trim());
|
||||
else await createReplyTemplate(title.trim(), text.trim());
|
||||
onSaved();
|
||||
} catch (caught) {
|
||||
setError(caught instanceof Error ? caught.message : t("settings.could_not_save_template"));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal open title={initial ? t("settings.edit_template") : t("settings.new_template")} onCancel={onClose} footer={null} destroyOnClose>
|
||||
<div className="integration-form">
|
||||
<FormField label={t("common.title")} value={title} onChange={setTitle} />
|
||||
<TextAreaField label={t("settings.template_text")} value={text} onChange={setText} inputRef={textRef} />
|
||||
<TemplateVariableMenu onPick={insertVariable} />
|
||||
{error && <div className="integration-form-error">{error}</div>}
|
||||
<div className="integration-form-actions">
|
||||
<Button variant="secondary" onClick={onClose}>{t("common.cancel")}</Button>
|
||||
<Button variant="primary" disabled={saving || !title.trim() || !text.trim()} onClick={() => void save()}>{t("common.save")}</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { Modal } from "antd";
|
||||
import { useState } from "react";
|
||||
|
||||
import { EmptyState } from "../../shared/ui";
|
||||
import { Icon } from "../../shared/icons";
|
||||
import { Button } from "../../shared/ui-controls";
|
||||
import { deleteReplyTemplate, type ReplyTemplateRef } from "../conversations/model";
|
||||
import { fmt, t } from "../../i18n";
|
||||
|
||||
// Раздел «Шаблоны ответов» (согласован по образцу кадра N2 «Группы», своего
|
||||
// кадра в базлайне нет): строка — название · начало текста · дата изменения ·
|
||||
// изменить/удалить. Создание — primary-кнопкой в шапке раздела, правка —
|
||||
// окном, как у интеграций. Без права на настройки список только читается.
|
||||
|
||||
export function ReplyTemplatesCard({ items, canManage, reload, onEdit }: {
|
||||
items: ReplyTemplateRef[];
|
||||
canManage: boolean;
|
||||
reload: () => void;
|
||||
onEdit: (template: ReplyTemplateRef) => void;
|
||||
}) {
|
||||
const [deleting, setDeleting] = useState<ReplyTemplateRef | null>(null);
|
||||
const [deleteError, setDeleteError] = useState("");
|
||||
|
||||
async function remove() {
|
||||
if (!deleting) return;
|
||||
setDeleteError("");
|
||||
try {
|
||||
await deleteReplyTemplate(deleting.id);
|
||||
setDeleting(null);
|
||||
reload();
|
||||
} catch (caught) {
|
||||
setDeleteError(caught instanceof Error ? caught.message : t("settings.could_not_delete"));
|
||||
}
|
||||
}
|
||||
|
||||
if (items.length === 0) return <EmptyState title={t("settings.no_templates_yet")} />;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="table-card settings-groups-card">
|
||||
{items.map((template) => (
|
||||
<div
|
||||
className={`settings-group-row settings-template-row ${canManage ? "is-clickable" : ""}`}
|
||||
key={template.id}
|
||||
onClick={canManage ? () => onEdit(template) : undefined}
|
||||
>
|
||||
<strong>{template.title}</strong>
|
||||
<span className="settings-template-preview">{template.text.replace(/\s+/g, " ")}</span>
|
||||
<small>{fmt.shortDate(template.updatedAt)}</small>
|
||||
{canManage && (
|
||||
<div className="settings-group-actions" onClick={(event) => event.stopPropagation()}>
|
||||
<button aria-label={t("common.edit")} title={t("common.edit")} type="button" onClick={() => onEdit(template)}><Icon name="edit" size={14} /></button>
|
||||
<button aria-label={t("common.delete")} title={t("common.delete")} className="is-danger" type="button" onClick={() => { setDeleteError(""); setDeleting(template); }}><Icon name="trash" size={14} /></button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{deleting && (
|
||||
<Modal open title={t("settings.delete_template")} onCancel={() => setDeleting(null)} footer={null} destroyOnClose>
|
||||
<div className="integration-form">
|
||||
<p>{t("settings.template_will_be_deleted", { name: deleting.title })}</p>
|
||||
{deleteError && <div className="integration-form-error">{deleteError}</div>}
|
||||
<div className="integration-form-actions">
|
||||
<Button variant="secondary" onClick={() => setDeleting(null)}>{t("common.cancel")}</Button>
|
||||
<Button variant="danger-outline" onClick={() => void remove()}>{t("common.delete")}</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -18,8 +18,12 @@ import { PlatformSettingsCard } from "./PlatformSettingsCard";
|
||||
import { StorageSettingsCard } from "./StorageSettingsCard";
|
||||
import { GroupsSettingsCard } from "./GroupsSettingsCard";
|
||||
import { QueuePolicyCard } from "./QueuePolicyCard";
|
||||
import { ReplyTemplateForm } from "./ReplyTemplateForm";
|
||||
import { ReplyTemplatesCard } from "./ReplyTemplatesCard";
|
||||
import { DEFAULT_SETTINGS_SECTION, visibleSettingsSections, type SettingsSectionKey } from "./sections";
|
||||
import { useIntegrations } from "./useIntegrations";
|
||||
import { useReplyTemplates } from "./useReplyTemplates";
|
||||
import type { ReplyTemplateRef } from "../conversations/model";
|
||||
import { useOnboarding } from "../onboarding/useOnboarding";
|
||||
import type { TourTarget } from "../onboarding/steps";
|
||||
import { t } from "../../i18n";
|
||||
@@ -50,6 +54,8 @@ export function SettingsPage({ user, onUserUpdated, reload, groups = [], section
|
||||
const manager = isManager(user);
|
||||
const integrations = useIntegrations(manager);
|
||||
const [form, setForm] = useState<{ kind: IntegrationKind; initial: Integration | null } | null>(null);
|
||||
const templates = useReplyTemplates(manager);
|
||||
const [templateForm, setTemplateForm] = useState<{ initial: ReplyTemplateRef | null } | null>(null);
|
||||
// Кадр M: на узком экране субменю и раздел — два отдельных экрана.
|
||||
const mobile = useMediaQuery("(max-width: 900px)");
|
||||
|
||||
@@ -60,6 +66,7 @@ export function SettingsPage({ user, onUserUpdated, reload, groups = [], section
|
||||
const connectionsFailed = connections.some((item) => item.isActive && item.status === "ERROR");
|
||||
const counts: Partial<Record<SettingsSectionKey, number>> = {
|
||||
groups: groups.length,
|
||||
templates: templates.items.length,
|
||||
ai: providers.length,
|
||||
integrations: connections.length,
|
||||
};
|
||||
@@ -121,7 +128,9 @@ export function SettingsPage({ user, onUserUpdated, reload, groups = [], section
|
||||
? <Button variant="primary" icon="plus" onClick={() => setForm({ kind: "LLM_PROVIDER", initial: null })}>{t("settings.add_provider")}</Button>
|
||||
: current?.key === "integrations"
|
||||
? <Button variant="primary" icon="plus" onClick={() => setForm({ kind: "MESSENGER", initial: null })}>{t("settings.add_connection")}</Button>
|
||||
: null;
|
||||
: current?.key === "templates" && canManageSettings(user)
|
||||
? <Button variant="primary" icon="plus" onClick={() => setTemplateForm({ initial: null })}>{t("settings.add_template")}</Button>
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div className="settings-layout">
|
||||
@@ -151,6 +160,8 @@ export function SettingsPage({ user, onUserUpdated, reload, groups = [], section
|
||||
providers={providers}
|
||||
connections={connections}
|
||||
onEditIntegration={(item) => setForm({ kind: item.kind, initial: item })}
|
||||
templates={templates}
|
||||
onEditTemplate={(item) => setTemplateForm({ initial: item })}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
@@ -163,11 +174,18 @@ export function SettingsPage({ user, onUserUpdated, reload, groups = [], section
|
||||
onSaved={() => { setForm(null); integrations.reload(); }}
|
||||
/>
|
||||
)}
|
||||
{templateForm && (
|
||||
<ReplyTemplateForm
|
||||
initial={templateForm.initial}
|
||||
onClose={() => setTemplateForm(null)}
|
||||
onSaved={() => { setTemplateForm(null); templates.reload(); }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SectionBody({ section, user, onUserUpdated, reload, groups, integrations, providers, connections, onEditIntegration }: {
|
||||
function SectionBody({ section, user, onUserUpdated, reload, groups, integrations, providers, connections, onEditIntegration, templates, onEditTemplate }: {
|
||||
section: SettingsSectionKey;
|
||||
user: SessionUser;
|
||||
onUserUpdated: (user: SessionUser) => void;
|
||||
@@ -177,6 +195,8 @@ function SectionBody({ section, user, onUserUpdated, reload, groups, integration
|
||||
providers: Integration[];
|
||||
connections: Integration[];
|
||||
onEditIntegration: (integration: Integration) => void;
|
||||
templates: ReturnType<typeof useReplyTemplates>;
|
||||
onEditTemplate: (template: ReplyTemplateRef) => void;
|
||||
}): ReactNode {
|
||||
if (section === "organization") return <OrganizationSection user={user} onUserUpdated={onUserUpdated} />;
|
||||
if (section === "groups") return <GroupsSettingsCard groups={groups} reload={reload} />;
|
||||
@@ -185,6 +205,11 @@ function SectionBody({ section, user, onUserUpdated, reload, groups, integration
|
||||
if (section === "platform") return <PlatformSettingsCard canManage={user.isInstanceAdmin} />;
|
||||
if (section === "storage") return <StorageSettingsCard canManage={user.isInstanceAdmin} />;
|
||||
if (section === "demo") return <DemoDataCard reload={reload} />;
|
||||
if (section === "templates") {
|
||||
if (templates.loading) return <LoadingState />;
|
||||
if (templates.failed) return <EmptyState title={t("settings.could_not_load_templates")} />;
|
||||
return <ReplyTemplatesCard items={templates.items} canManage={canManageSettings(user)} reload={templates.reload} onEdit={onEditTemplate} />;
|
||||
}
|
||||
if (integrations.loading) return <LoadingState />;
|
||||
if (integrations.failed) return <EmptyState title={t("settings.could_not_load_integrations")} />;
|
||||
return (
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { Dropdown } from "antd";
|
||||
|
||||
import { Icon } from "../../shared/icons";
|
||||
import { TEMPLATE_VARIABLES, TEMPLATE_VARIABLE_LABEL, type TemplateVariable } from "../conversations/templateVariables";
|
||||
import { t } from "../../i18n";
|
||||
|
||||
// «Вставить переменную» в окне шаблона ответа: коды переменных не нужно
|
||||
// помнить — пункт меню вставляет его в позицию курсора.
|
||||
|
||||
export function TemplateVariableMenu({ onPick }: { onPick: (variable: TemplateVariable) => void }) {
|
||||
const items = TEMPLATE_VARIABLES.map((variable) => ({
|
||||
key: variable,
|
||||
label: t(TEMPLATE_VARIABLE_LABEL[variable]),
|
||||
onClick: () => onPick(variable),
|
||||
}));
|
||||
return (
|
||||
<Dropdown menu={{ items }} trigger={["click"]} placement="bottomLeft" overlayClassName="app-dropdown">
|
||||
<button className="link has-icon template-variable-trigger" type="button">
|
||||
<Icon name="plus" size={13} strokeWidth={2.2} />{t("settings.insert_variable")}
|
||||
</button>
|
||||
</Dropdown>
|
||||
);
|
||||
}
|
||||
@@ -10,6 +10,7 @@ export type SettingsSectionKey =
|
||||
| "organization"
|
||||
| "groups"
|
||||
| "queue"
|
||||
| "templates"
|
||||
| "ai"
|
||||
| "integrations"
|
||||
| "communication"
|
||||
@@ -51,6 +52,14 @@ export const SETTINGS_SECTIONS: SettingsSection[] = [
|
||||
heading: t("settings.when_to_call_for_help"),
|
||||
lead: t("settings.queue_section_lead"),
|
||||
},
|
||||
{
|
||||
// Своего кадра в базлайне нет: раздел согласован по образцу «Групп» (N2).
|
||||
key: "templates",
|
||||
label: t("settings.reply_templates"),
|
||||
icon: "text",
|
||||
heading: t("settings.reply_templates"),
|
||||
lead: t("settings.reply_templates_lead"),
|
||||
},
|
||||
{
|
||||
key: "ai",
|
||||
label: t("settings.ai_provider"),
|
||||
|
||||
@@ -386,6 +386,36 @@
|
||||
margin: 10px 0 4px;
|
||||
}
|
||||
|
||||
/* --- Шаблоны ответов: строка «Групп», вместо состава — начало текста --- */
|
||||
|
||||
.settings-template-row.is-clickable {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.settings-template-row strong {
|
||||
flex: 0 1 auto;
|
||||
max-width: 40%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* «Вставить переменную» — ссылка под полем текста, не во всю ширину формы. */
|
||||
.template-variable-trigger {
|
||||
align-self: flex-start;
|
||||
margin-top: -6px;
|
||||
}
|
||||
|
||||
.settings-template-preview {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
color: var(--n-4);
|
||||
font-size: 12.5px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* --- N3/N4 Таблицы: шапка 11px капсом на подложке, строки 62px --- */
|
||||
|
||||
.integrations-section {
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
import { fetchReplyTemplates, type ReplyTemplateRef } from "../conversations/model";
|
||||
|
||||
// Шаблоны грузятся на весь экран «Настроек», как интеграции: субменю
|
||||
// показывает их число, а раздел — список.
|
||||
|
||||
export type ReplyTemplatesState = {
|
||||
items: ReplyTemplateRef[];
|
||||
loading: boolean;
|
||||
failed: boolean;
|
||||
reload: () => void;
|
||||
};
|
||||
|
||||
export function useReplyTemplates(enabled: boolean): ReplyTemplatesState {
|
||||
const [items, setItems] = useState<ReplyTemplateRef[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [failed, setFailed] = useState(false);
|
||||
|
||||
const reload = useCallback(() => {
|
||||
if (!enabled) return;
|
||||
setFailed(false);
|
||||
fetchReplyTemplates()
|
||||
.then(setItems)
|
||||
.catch(() => setFailed(true))
|
||||
.finally(() => setLoading(false));
|
||||
}, [enabled]);
|
||||
|
||||
useEffect(() => {
|
||||
reload();
|
||||
}, [reload]);
|
||||
|
||||
return { items, loading, failed, reload };
|
||||
}
|
||||
@@ -754,6 +754,10 @@ export const en: Record<MessageKey, Message> = {
|
||||
"conversations.started": "Started",
|
||||
"conversations.take_conversation": "Take the conversation",
|
||||
"conversations.templates": "Templates",
|
||||
"conversations.variable_client_name": "Customer name",
|
||||
"conversations.variable_operator_name": "Operator name",
|
||||
"conversations.variable_company": "Organization name",
|
||||
"conversations.fill_template_variables": "Fill in the text: {names}",
|
||||
"conversations.transcribe": "Transcribe",
|
||||
"conversations.transcribing": "Transcribing…",
|
||||
"conversations.type_message_shift_enter_line": "Type a message… Shift+Enter for a line break, “/” for a reply template",
|
||||
@@ -820,6 +824,9 @@ export const en: Record<MessageKey, Message> = {
|
||||
"portals.article_category_path_required": "Article #{index}: the required field “categoryPath” is a non-empty list of sections",
|
||||
"portals.article_content_empty": "Article #{index}: the required field “content” is empty",
|
||||
"portals.article_contents": "Article contents",
|
||||
"portals.related_articles": "Related articles",
|
||||
"portals.expand_section": "Expand section {name}",
|
||||
"portals.collapse_section": "Collapse section {name}",
|
||||
"portals.article_files": "ARTICLE FILES",
|
||||
"portals.article_import": "Article import",
|
||||
"portals.article_must_be_object": "Article #{index}: must be an object",
|
||||
@@ -1474,6 +1481,18 @@ export const en: Record<MessageKey, Message> = {
|
||||
"settings.queue_restore_defaults": "Restore the usual delays",
|
||||
"settings.queue_policy_unavailable": "Could not load the delays",
|
||||
"settings.queue_delivery_warning": "Reminders arrive where the employee will see them: the browser and the linked messenger. If a group has neither, only the counter in the conversation list is left.",
|
||||
"settings.reply_templates": "Reply templates",
|
||||
"settings.reply_templates_lead": "Ready-made replies for operators. In a conversation, type “/” in the message field to insert one.",
|
||||
"settings.add_template": "Add a template",
|
||||
"settings.no_templates_yet": "No templates yet. Add the first one — operators will call it up with “/”.",
|
||||
"settings.could_not_load_templates": "Could not load the templates",
|
||||
"settings.new_template": "New template",
|
||||
"settings.edit_template": "Edit the template",
|
||||
"settings.template_text": "Text",
|
||||
"settings.insert_variable": "Insert a variable",
|
||||
"settings.could_not_save_template": "Could not save the template",
|
||||
"settings.delete_template": "Delete the template?",
|
||||
"settings.template_will_be_deleted": "The template “{name}” will be deleted — operators will no longer be able to insert it.",
|
||||
"settings.ai_provider": "AI provider",
|
||||
"settings.allowed_domains": "Allowed domains",
|
||||
"settings.answers_from_agent_s_knowledge": "Answers from the agent's knowledge with no external requests or keys. Answer quality is limited — for real work connect OpenRouter or a compatible provider.",
|
||||
|
||||
@@ -755,6 +755,10 @@ export const ru = {
|
||||
"conversations.started": "Начат",
|
||||
"conversations.take_conversation": "Взять диалог",
|
||||
"conversations.templates": "Шаблоны",
|
||||
"conversations.variable_client_name": "Имя клиента",
|
||||
"conversations.variable_operator_name": "Имя оператора",
|
||||
"conversations.variable_company": "Название организации",
|
||||
"conversations.fill_template_variables": "Заполните в тексте: {names}",
|
||||
"conversations.transcribe": "Расшифровать",
|
||||
"conversations.transcribing": "Расшифровываем…",
|
||||
"conversations.type_message_shift_enter_line": "Введите сообщение… Shift+Enter — перенос строки, «/» — шаблон ответа",
|
||||
@@ -821,6 +825,9 @@ export const ru = {
|
||||
"portals.article_category_path_required": "Статья #{index}: обязательное поле «categoryPath» — непустой список разделов",
|
||||
"portals.article_content_empty": "Статья #{index}: обязательное поле «content» пустое",
|
||||
"portals.article_contents": "Содержание статьи",
|
||||
"portals.related_articles": "Связанные статьи",
|
||||
"portals.expand_section": "Раскрыть раздел {name}",
|
||||
"portals.collapse_section": "Свернуть раздел {name}",
|
||||
"portals.article_files": "ФАЙЛЫ СТАТЬИ",
|
||||
"portals.article_import": "Импорт статей",
|
||||
"portals.article_must_be_object": "Статья #{index}: должна быть объектом",
|
||||
@@ -1475,6 +1482,18 @@ export const ru = {
|
||||
"settings.queue_restore_defaults": "Вернуть обычные сроки",
|
||||
"settings.queue_policy_unavailable": "Не удалось загрузить сроки",
|
||||
"settings.queue_delivery_warning": "Напоминания приходят туда, где сотрудник их получит: в браузер и в привязанный мессенджер. Если у группы не настроен ни один способ, останется только счётчик в списке диалогов.",
|
||||
"settings.reply_templates": "Шаблоны ответов",
|
||||
"settings.reply_templates_lead": "Готовые ответы для операторов. В диалоге шаблон вставляется, если набрать «/» в поле сообщения.",
|
||||
"settings.add_template": "Добавить шаблон",
|
||||
"settings.no_templates_yet": "Шаблонов пока нет. Добавьте первый — операторы вызовут его через «/».",
|
||||
"settings.could_not_load_templates": "Не удалось загрузить шаблоны",
|
||||
"settings.new_template": "Новый шаблон",
|
||||
"settings.edit_template": "Изменить шаблон",
|
||||
"settings.template_text": "Текст",
|
||||
"settings.insert_variable": "Вставить переменную",
|
||||
"settings.could_not_save_template": "Не удалось сохранить шаблон",
|
||||
"settings.delete_template": "Удалить шаблон?",
|
||||
"settings.template_will_be_deleted": "Шаблон «{name}» будет удалён — операторы больше не смогут его вставить.",
|
||||
"settings.ai_provider": "AI-провайдер",
|
||||
"settings.allowed_domains": "Разрешённые домены",
|
||||
"settings.answers_from_agent_s_knowledge": "Отвечает по знаниям агента без внешних запросов и ключей. Качество ответов ограничено — для реальной работы подключите OpenRouter или совместимый провайдер.",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useRef, useState, type ReactNode } from "react";
|
||||
import { useRef, useState, type ReactNode, type RefObject } from "react";
|
||||
|
||||
import { Icon } from "./icons";
|
||||
import { SelectMenu } from "./ui-controls";
|
||||
@@ -121,12 +121,13 @@ export function SelectField({ adornment, disabled = false, invalid = false, labe
|
||||
);
|
||||
}
|
||||
|
||||
export function TextAreaField({ disabled = false, label, value, onChange }: { disabled?: boolean; label: string; value: string; onChange: (value: string) => void }) {
|
||||
/** `inputRef` — для вставки в позицию курсора (переменные шаблона ответа). */
|
||||
export function TextAreaField({ disabled = false, label, value, onChange, inputRef }: { disabled?: boolean; label: string; value: string; onChange: (value: string) => void; inputRef?: RefObject<HTMLTextAreaElement | null> }) {
|
||||
const className = ["readonly-field", "form-field", "wide", disabled ? "is-readonly is-disabled" : "is-editable"].join(" ");
|
||||
return (
|
||||
<label className={className}>
|
||||
<span>{label}</span>
|
||||
<textarea value={value} disabled={disabled} readOnly={disabled} onChange={(event) => onChange(event.target.value)} />
|
||||
<textarea ref={inputRef} value={value} disabled={disabled} readOnly={disabled} onChange={(event) => onChange(event.target.value)} />
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
# Редакционный порядок центра помощи Chatballs
|
||||
|
||||
`chatballs.json` задаёт порядок опубликованных статей внутри каждого раздела и
|
||||
направленные связи между статьями. Карта охватывает 72 русскоязычные статьи и
|
||||
170 переходов. Она не меняет тексты, категории и статусы публикации.
|
||||
|
||||
Команда `apply_portal_curation` сначала проверяет, что карта полностью совпадает
|
||||
с опубликованными статьями выбранного портала: состав, разделы, язык и ссылки.
|
||||
Без `--apply` она только показывает число записей, которые были бы изменены.
|
||||
|
||||
После штатной выкладки версии с миграцией `0012_article_curation`:
|
||||
|
||||
```sh
|
||||
cat chatballs.json | ssh ubuntu@SERVER \
|
||||
'cd /home/ubuntu && docker compose exec -T backend-admin \
|
||||
python /app/apps/backend/manage.py apply_portal_curation \
|
||||
--host chatballs.com.edevs.tech --file -'
|
||||
```
|
||||
|
||||
После сверки результата тот же запуск с `--apply` сохраняет порядок и связи.
|
||||
Повторный запуск идемпотентен: при уже применённой карте число изменений равно
|
||||
нулю. Файл передаётся через stdin и не сохраняется на сервере, поскольку
|
||||
production-образ не содержит этот каталог документации.
|
||||
@@ -0,0 +1,92 @@
|
||||
{
|
||||
"portal": "chatballs",
|
||||
"locale": "ru",
|
||||
"order": {
|
||||
"start": ["chto-takoe-chatballs", "pervyj-den", "demo-dannye", "roli-i-prava"],
|
||||
"installation": ["pervyj-zapusk", "adres-i-https", "pochta-smtp", "yazyk-ustanovki", "hranilishche-fajlov", "rezervnoe-kopirovanie", "obnovlenie", "diagnostika", "zvonki-relay"],
|
||||
"organization": ["organizaciya-nastrojki", "ai-provajder", "gruppy", "kogda-zvat-na-pomoshch", "neskolko-organizacij"],
|
||||
"entry-points": ["tochki-vhoda-obzor", "telegram", "token-telegram", "max", "token-max", "veb-vidzhet", "pochtovyj-yashchik", "vkontakte", "golosovye-i-zvonki", "bot-uvedomlenij"],
|
||||
"agents": ["kartochka-agenta", "model-i-provajder", "instrukcii-agenta", "znaniya-agenta", "zapusk-i-ostanovka", "agent-ne-znaet-otveta", "yazyk-otveta"],
|
||||
"knowledge": ["material-i-vlozheniya", "kategorii-znanij", "kak-agent-nahodit-otvet", "import-yaml-znaniya", "massovye-dejstviya"],
|
||||
"portals": ["sozdanie-portala", "kategorii-portala", "statya-portala", "revizii-i-publikaciya", "oformlenie-portala", "svoj-domen-portala", "import-statej", "portal-i-agent"],
|
||||
"conversations": ["ekran-chat", "rezhimy-dialoga", "razbor-dialoga", "kto-vidit-dialogi", "shablony-otvetov", "fajly-golosovye-zvonki", "zakrytie-spam-udalenie"],
|
||||
"contacts": ["spisok-kontaktov", "kartochka-kontakta", "zapros-kontakta", "obedinenie-dublej"],
|
||||
"team": ["roli-sotrudnikov", "priglashenie-sotrudnika", "gruppy-sotrudnika", "bezopasnost-sotrudnika", "zhurnal-audita", "peredacha-vladeniya"],
|
||||
"profile": ["profil-dannye", "profil-bezopasnost", "profil-uvedomleniya"],
|
||||
"reference": ["chto-delat-esli", "glossarij", "formaty-i-razmery-fajlov", "chto-uhodit-v-model"]
|
||||
},
|
||||
"related": {
|
||||
"chto-takoe-chatballs": ["pervyj-den", "roli-i-prava", "demo-dannye"],
|
||||
"pervyj-den": ["demo-dannye", "pervyj-zapusk", "tochki-vhoda-obzor"],
|
||||
"demo-dannye": ["pervyj-den", "ai-provajder"],
|
||||
"roli-i-prava": ["roli-sotrudnikov", "priglashenie-sotrudnika"],
|
||||
"pervyj-zapusk": ["adres-i-https", "pochta-smtp", "rezervnoe-kopirovanie"],
|
||||
"adres-i-https": ["svoj-domen-portala", "diagnostika"],
|
||||
"pochta-smtp": ["priglashenie-sotrudnika", "pochtovyj-yashchik"],
|
||||
"yazyk-ustanovki": ["organizaciya-nastrojki", "profil-dannye", "yazyk-otveta"],
|
||||
"hranilishche-fajlov": ["rezervnoe-kopirovanie", "formaty-i-razmery-fajlov"],
|
||||
"rezervnoe-kopirovanie": ["obnovlenie", "diagnostika"],
|
||||
"obnovlenie": ["rezervnoe-kopirovanie", "diagnostika"],
|
||||
"diagnostika": ["chto-delat-esli", "adres-i-https", "rezervnoe-kopirovanie"],
|
||||
"zvonki-relay": ["golosovye-i-zvonki", "fajly-golosovye-zvonki"],
|
||||
"organizaciya-nastrojki": ["ai-provajder", "gruppy", "kogda-zvat-na-pomoshch"],
|
||||
"ai-provajder": ["model-i-provajder", "kartochka-agenta"],
|
||||
"gruppy": ["gruppy-sotrudnika", "kto-vidit-dialogi"],
|
||||
"kogda-zvat-na-pomoshch": ["agent-ne-znaet-otveta", "razbor-dialoga"],
|
||||
"neskolko-organizacij": ["organizaciya-nastrojki", "roli-i-prava"],
|
||||
"tochki-vhoda-obzor": ["telegram", "max", "veb-vidzhet"],
|
||||
"telegram": ["token-telegram", "kartochka-agenta", "diagnostika"],
|
||||
"token-telegram": ["telegram", "bot-uvedomlenij"],
|
||||
"max": ["token-max", "telegram"],
|
||||
"token-max": ["max", "token-telegram"],
|
||||
"veb-vidzhet": ["golosovye-i-zvonki", "oformlenie-portala", "tochki-vhoda-obzor"],
|
||||
"pochtovyj-yashchik": ["pochta-smtp", "tochki-vhoda-obzor"],
|
||||
"vkontakte": ["tochki-vhoda-obzor", "kartochka-agenta"],
|
||||
"golosovye-i-zvonki": ["fajly-golosovye-zvonki", "zvonki-relay"],
|
||||
"bot-uvedomlenij": ["profil-uvedomleniya", "token-telegram", "token-max"],
|
||||
"kartochka-agenta": ["model-i-provajder", "instrukcii-agenta", "znaniya-agenta"],
|
||||
"model-i-provajder": ["ai-provajder", "yazyk-otveta"],
|
||||
"instrukcii-agenta": ["znaniya-agenta", "agent-ne-znaet-otveta"],
|
||||
"znaniya-agenta": ["kategorii-znanij", "material-i-vlozheniya", "kak-agent-nahodit-otvet"],
|
||||
"zapusk-i-ostanovka": ["kartochka-agenta", "tochki-vhoda-obzor"],
|
||||
"agent-ne-znaet-otveta": ["znaniya-agenta", "kogda-zvat-na-pomoshch", "rezhimy-dialoga"],
|
||||
"yazyk-otveta": ["yazyk-ustanovki", "instrukcii-agenta"],
|
||||
"material-i-vlozheniya": ["kategorii-znanij", "kak-agent-nahodit-otvet", "statya-portala"],
|
||||
"kategorii-znanij": ["massovye-dejstviya", "znaniya-agenta"],
|
||||
"kak-agent-nahodit-otvet": ["znaniya-agenta", "material-i-vlozheniya", "agent-ne-znaet-otveta"],
|
||||
"import-yaml-znaniya": ["kategorii-znanij", "massovye-dejstviya"],
|
||||
"massovye-dejstviya": ["kategorii-znanij", "import-yaml-znaniya"],
|
||||
"sozdanie-portala": ["kategorii-portala", "statya-portala", "oformlenie-portala"],
|
||||
"kategorii-portala": ["statya-portala", "import-statej"],
|
||||
"statya-portala": ["revizii-i-publikaciya", "portal-i-agent", "material-i-vlozheniya"],
|
||||
"revizii-i-publikaciya": ["statya-portala", "oformlenie-portala"],
|
||||
"oformlenie-portala": ["svoj-domen-portala", "veb-vidzhet"],
|
||||
"svoj-domen-portala": ["adres-i-https", "sozdanie-portala"],
|
||||
"import-statej": ["kategorii-portala", "statya-portala"],
|
||||
"portal-i-agent": ["znaniya-agenta", "material-i-vlozheniya", "statya-portala"],
|
||||
"ekran-chat": ["rezhimy-dialoga", "razbor-dialoga", "kto-vidit-dialogi"],
|
||||
"rezhimy-dialoga": ["agent-ne-znaet-otveta", "razbor-dialoga", "kto-vidit-dialogi"],
|
||||
"razbor-dialoga": ["gruppy", "kto-vidit-dialogi", "ekran-chat"],
|
||||
"kto-vidit-dialogi": ["gruppy", "gruppy-sotrudnika"],
|
||||
"shablony-otvetov": ["ekran-chat", "razbor-dialoga"],
|
||||
"fajly-golosovye-zvonki": ["golosovye-i-zvonki", "hranilishche-fajlov"],
|
||||
"zakrytie-spam-udalenie": ["rezhimy-dialoga", "razbor-dialoga"],
|
||||
"spisok-kontaktov": ["kartochka-kontakta", "obedinenie-dublej"],
|
||||
"kartochka-kontakta": ["spisok-kontaktov", "zapros-kontakta", "obedinenie-dublej"],
|
||||
"zapros-kontakta": ["kartochka-kontakta", "telegram"],
|
||||
"obedinenie-dublej": ["kartochka-kontakta", "spisok-kontaktov"],
|
||||
"roli-sotrudnikov": ["roli-i-prava", "priglashenie-sotrudnika"],
|
||||
"priglashenie-sotrudnika": ["pochta-smtp", "gruppy-sotrudnika", "roli-sotrudnikov"],
|
||||
"gruppy-sotrudnika": ["gruppy", "kto-vidit-dialogi"],
|
||||
"bezopasnost-sotrudnika": ["profil-bezopasnost", "zhurnal-audita"],
|
||||
"zhurnal-audita": ["bezopasnost-sotrudnika", "peredacha-vladeniya"],
|
||||
"peredacha-vladeniya": ["roli-sotrudnikov", "bezopasnost-sotrudnika"],
|
||||
"profil-dannye": ["yazyk-ustanovki", "profil-uvedomleniya"],
|
||||
"profil-bezopasnost": ["bezopasnost-sotrudnika", "profil-dannye"],
|
||||
"profil-uvedomleniya": ["bot-uvedomlenij", "profil-dannye"],
|
||||
"chto-delat-esli": ["diagnostika", "agent-ne-znaet-otveta", "pochta-smtp"],
|
||||
"glossarij": ["chto-takoe-chatballs", "tochki-vhoda-obzor"],
|
||||
"formaty-i-razmery-fajlov": ["hranilishche-fajlov", "material-i-vlozheniya", "statya-portala"],
|
||||
"chto-uhodit-v-model": ["ai-provajder", "profil-bezopasnost", "znaniya-agenta"]
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user